feat: add Tauri desktop app support and terminal management
This commit is contained in:
@@ -28,6 +28,8 @@ import {
|
||||
HorizontalScrollProvider,
|
||||
useHorizontalScrollOptional,
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
|
||||
function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
@@ -152,11 +154,17 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
[isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, openAgentList, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX]
|
||||
);
|
||||
|
||||
// When sidebar is collapsed on desktop Tauri macOS, add left padding for traffic lights
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const needsTrafficLightPadding = !isMobile && !isOpen && getIsTauriMac();
|
||||
|
||||
const content = (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||
{!isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={{ flex: 1 }}>{children}</View>
|
||||
<View style={{ flex: 1, paddingLeft: needsTrafficLightPadding ? trafficLightPadding.left : 0 }}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
{isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
<DownloadToast />
|
||||
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
GitBranch,
|
||||
Folder,
|
||||
RotateCcw,
|
||||
Users,
|
||||
ChevronRight,
|
||||
PlusIcon,
|
||||
PanelRight,
|
||||
} from "lucide-react-native";
|
||||
@@ -51,7 +49,6 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
@@ -61,6 +58,7 @@ import {
|
||||
} from "@/utils/navigation-timing";
|
||||
import { extractAgentModel } from "@/utils/extract-agent-model";
|
||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
@@ -294,35 +292,6 @@ function AgentScreenContent({
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Select the agents Map directly - this is a stable reference that only changes when agents are added/removed
|
||||
const allAgents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.agents
|
||||
);
|
||||
|
||||
// Derive child agents in useMemo to avoid creating new references on every store update
|
||||
// This prevents infinite loops caused by useShallow not doing deep equality on object arrays
|
||||
const childAgents = useMemo(() => {
|
||||
if (!resolvedAgentId || !allAgents) return [];
|
||||
const children: Array<{
|
||||
id: string;
|
||||
title: string | null;
|
||||
createdAt: Date;
|
||||
status: Agent["status"];
|
||||
}> = [];
|
||||
for (const [id, a] of allAgents) {
|
||||
if (a.parentAgentId === resolvedAgentId) {
|
||||
children.push({
|
||||
id,
|
||||
title: a.title,
|
||||
createdAt: a.createdAt,
|
||||
status: a.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
children.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
return children;
|
||||
}, [allAgents, resolvedAgentId]);
|
||||
|
||||
// Select only the specific stream tail - use stable empty array to avoid infinite loop
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
resolvedAgentId
|
||||
@@ -613,20 +582,6 @@ function AgentScreenContent({
|
||||
router.push({ pathname: "/", params });
|
||||
}, [agent, agentModel, handleCloseMenu, router, serverId]);
|
||||
|
||||
const handleNavigateToChildAgent = useCallback(
|
||||
(childAgentId: string) => {
|
||||
handleCloseMenu();
|
||||
router.push({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: {
|
||||
serverId: serverId,
|
||||
agentId: childAgentId,
|
||||
},
|
||||
});
|
||||
},
|
||||
[handleCloseMenu, router, serverId]
|
||||
);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -736,7 +691,7 @@ function AgentScreenContent({
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{agent.cwd}
|
||||
{shortenPath(agent.cwd)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -784,50 +739,6 @@ function AgentScreenContent({
|
||||
|
||||
<View style={styles.menuDivider} />
|
||||
|
||||
{/* Sub-Agents Section */}
|
||||
<View style={styles.menuSubAgentsSection}>
|
||||
<View style={styles.menuSubAgentsHeader}>
|
||||
<Users size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.menuSubAgentsLabel}>Sub-Agents</Text>
|
||||
</View>
|
||||
{childAgents.length === 0 ? (
|
||||
<Text style={styles.menuSubAgentsEmpty}>No sub-agents</Text>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.menuSubAgentsList}
|
||||
contentContainerStyle={styles.menuSubAgentsListContent}
|
||||
showsVerticalScrollIndicator={childAgents.length > 3}
|
||||
>
|
||||
{childAgents.map((child) => (
|
||||
<Pressable
|
||||
key={child.id}
|
||||
onPress={() => handleNavigateToChildAgent(child.id)}
|
||||
style={styles.menuSubAgentItem}
|
||||
>
|
||||
<Text
|
||||
style={styles.menuSubAgentText}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{child.title || "Untitled Agent"}
|
||||
</Text>
|
||||
<View style={styles.menuSubAgentMeta}>
|
||||
{child.status === "running" ? (
|
||||
<View style={styles.menuSubAgentDot} />
|
||||
) : null}
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.menuDivider} />
|
||||
|
||||
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
|
||||
<GitBranch size={16} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>View Changes</Text>
|
||||
@@ -1108,57 +1019,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
menuSubAgentsSection: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
menuSubAgentsHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
menuSubAgentsLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
menuSubAgentsEmpty: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
menuSubAgentsList: {
|
||||
maxHeight: (theme.spacing[2] * 2 + theme.fontSize.base) * 3,
|
||||
},
|
||||
menuSubAgentsListContent: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuSubAgentItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
menuSubAgentText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
menuSubAgentMeta: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
menuSubAgentDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#3b82f6",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function HomeScreen() {
|
||||
const resolvedModel = getParamValue(params.model);
|
||||
const resolvedWorkingDir = getParamValue(params.workingDir);
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const initialValues = useMemo((): CreateAgentInitialValues => {
|
||||
const values: CreateAgentInitialValues = {};
|
||||
if (resolvedWorkingDir) {
|
||||
values.workingDir = resolvedWorkingDir;
|
||||
@@ -117,8 +117,12 @@ export default function HomeScreen() {
|
||||
if (resolvedMode) {
|
||||
values.modeId = resolvedMode;
|
||||
}
|
||||
if (resolvedModel) {
|
||||
values.model = resolvedModel;
|
||||
}
|
||||
return values;
|
||||
}, [resolvedMode, resolvedProvider, resolvedWorkingDir]);
|
||||
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]);
|
||||
|
||||
const {
|
||||
selectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
@@ -143,21 +147,6 @@ export default function HomeScreen() {
|
||||
isVisible: true,
|
||||
isCreateFlow: true,
|
||||
});
|
||||
const hasAppliedModelParamRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!resolvedModel || hasAppliedModelParamRef.current) {
|
||||
return;
|
||||
}
|
||||
if (availableModels.length === 0) {
|
||||
return;
|
||||
}
|
||||
const isValidModel = availableModels.some((model) => model.id === resolvedModel);
|
||||
hasAppliedModelParamRef.current = true;
|
||||
if (!isValidModel) {
|
||||
return;
|
||||
}
|
||||
setModelFromUser(resolvedModel);
|
||||
}, [availableModels, resolvedModel, setModelFromUser]);
|
||||
const hostEntry = selectedServerId
|
||||
? connectionStates.get(selectedServerId)
|
||||
: undefined;
|
||||
|
||||
@@ -340,6 +340,37 @@ const styles = StyleSheet.create((theme) => ({
|
||||
themeToggleTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
// Dev section
|
||||
devCard: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
devButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
devButtonBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
devButtonContent: {
|
||||
flex: 1,
|
||||
},
|
||||
devButtonTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
devButtonDescription: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
type DaemonTestState = {
|
||||
@@ -760,6 +791,24 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Developer */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Developer</Text>
|
||||
<View style={styles.devCard}>
|
||||
<Pressable
|
||||
style={styles.devButton}
|
||||
onPress={() => router.push("/audio-test")}
|
||||
>
|
||||
<View style={styles.devButtonContent}>
|
||||
<Text style={styles.devButtonTitle}>Audio Test</Text>
|
||||
<Text style={styles.devButtonDescription}>
|
||||
Test audio recording and playback
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerAppInfo}>
|
||||
|
||||
@@ -101,10 +101,6 @@ export function AgentInputArea({
|
||||
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
|
||||
// Noop fallback for sendAgentAudio (required by MessageInput)
|
||||
const noopSendAgentAudio = useCallback(async () => {}, []);
|
||||
const sendAgentAudio = methods?.sendAgentAudio ?? noopSendAgentAudio;
|
||||
|
||||
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
||||
|
||||
const [internalInput, setInternalInput] = useState("");
|
||||
@@ -448,52 +444,47 @@ export function AgentInputArea({
|
||||
]
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handleRealtimePress}
|
||||
disabled={!isConnected && !isRealtimeMode}
|
||||
style={[
|
||||
styles.realtimeButton as any,
|
||||
(isRealtimeMode ? styles.realtimeButtonActive : undefined) as any,
|
||||
(!isConnected && !isRealtimeMode
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isRealtimeMode ? (
|
||||
<Square
|
||||
size={18}
|
||||
color={theme.colors.background}
|
||||
fill={theme.colors.background}
|
||||
/>
|
||||
) : (
|
||||
<AudioLines size={20} color={theme.colors.background} />
|
||||
)}
|
||||
</Pressable>
|
||||
{isAgentRunning && (
|
||||
<Pressable
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={
|
||||
isCancellingAgent ? "Canceling agent" : "Stop agent"
|
||||
}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!isConnected || isCancellingAgent
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
const rightContent = isAgentRunning ? (
|
||||
<Pressable
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!isConnected || isCancellingAgent
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
)}
|
||||
</>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={handleRealtimePress}
|
||||
disabled={!isConnected && !isRealtimeMode}
|
||||
style={[
|
||||
styles.realtimeButton as any,
|
||||
(isRealtimeMode ? styles.realtimeButtonActive : undefined) as any,
|
||||
(!isConnected && !isRealtimeMode
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
>
|
||||
{isRealtimeMode ? (
|
||||
<Square
|
||||
size={18}
|
||||
color={theme.colors.background}
|
||||
fill={theme.colors.background}
|
||||
/>
|
||||
) : (
|
||||
<AudioLines size={20} color={theme.colors.background} />
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
const leftContent = <AgentStatusBar agentId={agentId} serverId={serverId} />;
|
||||
@@ -574,7 +565,6 @@ export function AgentInputArea({
|
||||
onPickImages={handlePickImage}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
client={client}
|
||||
sendAgentAudio={sendAgentAudio}
|
||||
placeholder="Message agent..."
|
||||
autoFocus={autoFocus}
|
||||
disabled={isRealtimeMode}
|
||||
@@ -630,10 +620,10 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
},
|
||||
cancelButton: {
|
||||
width: BUTTON_SIZE,
|
||||
height: BUTTON_SIZE,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.red[500],
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCallback, useState, type ReactElement } from "react";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -144,7 +145,7 @@ export function AgentList({
|
||||
</View>
|
||||
|
||||
<Text style={styles.secondaryRow} numberOfLines={1}>
|
||||
{agent.cwd.replace(/^\/(?:Users|home)\/[^/]+/, "~")} · {timeAgo}
|
||||
{shortenPath(agent.cwd)} · {timeAgo}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -495,12 +495,6 @@ export function AgentStreamView({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{showWorkingIndicator ? (
|
||||
<View style={stylesheet.workingIndicatorWrapper}>
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{hasHeadItems
|
||||
? [...streamHead].reverse().map((item, index) => {
|
||||
const rendered = renderStreamItemContent(item, index);
|
||||
@@ -511,6 +505,12 @@ export function AgentStreamView({
|
||||
) : null;
|
||||
})
|
||||
: null}
|
||||
|
||||
{showWorkingIndicator ? (
|
||||
<View style={stylesheet.workingIndicatorWrapper}>
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -555,10 +555,12 @@ export function AgentStreamView({
|
||||
}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={flatListExtraData}
|
||||
maintainVisibleContentPosition={{
|
||||
minIndexForVisible: 0,
|
||||
autoscrollToTopThreshold: 40,
|
||||
}}
|
||||
maintainVisibleContentPosition={
|
||||
// Disable when streaming and user is at bottom - we handle auto-scroll ourselves
|
||||
agent.status === "running" && isNearBottom
|
||||
? undefined
|
||||
: { minIndexForVisible: 0, autoscrollToTopThreshold: 40 }
|
||||
}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
inverted
|
||||
@@ -777,8 +779,8 @@ function PermissionRequestCard({
|
||||
}, [request]);
|
||||
|
||||
const toolCallDisplay = useMemo(
|
||||
() => parseToolCallDisplay(request.input, null),
|
||||
[request.input]
|
||||
() => parseToolCallDisplay(request.name ?? "unknown", request.input, null),
|
||||
[request.name, request.input]
|
||||
);
|
||||
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
@@ -792,7 +794,7 @@ function PermissionRequestCard({
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]} selectable>
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
@@ -806,7 +808,6 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.textgroup]}
|
||||
selectable
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -821,7 +822,6 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.code_block]}
|
||||
selectable
|
||||
>
|
||||
{node.content}
|
||||
</Text>
|
||||
@@ -833,7 +833,7 @@ function PermissionRequestCard({
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]} selectable>
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
@@ -847,7 +847,6 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.code_inline]}
|
||||
selectable
|
||||
>
|
||||
{node.content}
|
||||
</Text>
|
||||
@@ -893,7 +892,6 @@ function PermissionRequestCard({
|
||||
<Text style={iconStyle}>{bullet}</Text>
|
||||
<Text
|
||||
style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}
|
||||
selectable
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
|
||||
@@ -117,13 +117,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "rgba(46, 160, 67, 0.15)",
|
||||
},
|
||||
addText: {
|
||||
color: theme.colors.palette.green[800],
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
removeLine: {
|
||||
backgroundColor: "rgba(248, 81, 73, 0.1)",
|
||||
},
|
||||
removeText: {
|
||||
color: theme.colors.palette.red[800],
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
addHighlight: {
|
||||
backgroundColor: "rgba(46, 160, 67, 0.4)",
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ViewMode,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
|
||||
type ExplorerTab = "changes" | "files";
|
||||
|
||||
interface ExplorerSidebarProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
@@ -309,13 +308,11 @@ function SidebarContent({
|
||||
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{activeTab === "changes" ? (
|
||||
{activeTab === "changes" && (
|
||||
<GitDiffPane serverId={serverId} agentId={agentId} />
|
||||
) : (
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "files" && (
|
||||
<FileExplorerPane serverId={serverId} agentId={agentId} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -130,7 +130,6 @@ export function FileExplorerPane({
|
||||
return sorted;
|
||||
}, [rawEntries, sortOption]);
|
||||
const showInitialListLoading = isListingLoading && entries.length === 0;
|
||||
const showListLoadingBanner = isListingLoading && entries.length > 0;
|
||||
const isPreviewLoading = Boolean(
|
||||
isExplorerLoading &&
|
||||
pendingRequest?.mode === "file" &&
|
||||
@@ -585,14 +584,9 @@ export function FileExplorerPane({
|
||||
<View style={styles.entryIcon}>
|
||||
{renderEntryIcon(displayKind, theme.colors)}
|
||||
</View>
|
||||
<View style={styles.entryTextContainer}>
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={styles.entryMeta} numberOfLines={1}>
|
||||
{formatFileSize({ size: item.size })} · {formatTimeAgo(new Date(item.modifiedAt))}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={(event) => handleOpenMenu(item, event)}
|
||||
@@ -642,17 +636,9 @@ export function FileExplorerPane({
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{showListLoadingBanner && (
|
||||
<View style={styles.loadingBanner}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingBannerText}>
|
||||
Loading {formatDirectoryLabel(activePath)}...
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, [activePath, currentSortLabel, explorerState?.history?.length, handleNavigateBack, handleSortCycle, showListLoadingBanner, theme.colors.mutedForeground]);
|
||||
}, [activePath, currentSortLabel, explorerState?.history?.length, handleNavigateBack, handleSortCycle]);
|
||||
|
||||
// Watch for completed file previews and process queue
|
||||
useEffect(() => {
|
||||
@@ -789,6 +775,15 @@ export function FileExplorerPane({
|
||||
]}
|
||||
onLayout={handleMenuLayout}
|
||||
>
|
||||
<View style={styles.entryMenuHeader}>
|
||||
<Text style={styles.entryMenuMeta}>
|
||||
{formatFileSize({ size: menuEntry.size })}
|
||||
</Text>
|
||||
<Text style={styles.entryMenuMeta}>
|
||||
{formatTimeAgo(new Date(menuEntry.modifiedAt))}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.entryMenuDivider} />
|
||||
<Pressable
|
||||
style={styles.entryMenuItem}
|
||||
onPress={() => {
|
||||
@@ -866,7 +861,7 @@ export function FileExplorerPane({
|
||||
) : (
|
||||
<View style={styles.sheetCenterState}>
|
||||
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||
<Text style={styles.entryMeta}>
|
||||
<Text style={styles.entryMenuMeta}>
|
||||
{formatFileSize({ size: preview.size })}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -1035,17 +1030,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
loadingBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
loadingBannerText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
sortButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1114,8 +1098,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1128,24 +1112,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
columnGap: theme.spacing[2],
|
||||
marginRight: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
entryIcon: {
|
||||
width: 28,
|
||||
alignItems: "center",
|
||||
},
|
||||
entryTextContainer: {
|
||||
flex: 1,
|
||||
flexShrink: 0,
|
||||
},
|
||||
entryName: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
entryMeta: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
menuButton: {
|
||||
width: 36,
|
||||
@@ -1168,6 +1144,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
entryMenuHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
entryMenuMeta: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
entryMenuDivider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
marginHorizontal: theme.spacing[2],
|
||||
marginVertical: theme.spacing[1],
|
||||
},
|
||||
entryMenuItem: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -21,10 +22,14 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? 8 : 0;
|
||||
|
||||
// On Tauri macOS, enable window dragging and double-click to maximize
|
||||
// Left padding for traffic lights is handled by _layout.tsx when sidebar is collapsed
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.row} {...dragHandlers}>
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||
</View>
|
||||
@@ -46,6 +51,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
userSelect: "none",
|
||||
},
|
||||
left: {
|
||||
flex: 1,
|
||||
|
||||
@@ -27,7 +27,6 @@ import Animated, {
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "./dictation-controls";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
export interface ImageAttachment {
|
||||
@@ -52,7 +51,6 @@ export interface MessageInputProps {
|
||||
onPickImages?: () => void;
|
||||
onRemoveImage?: (index: number) => void;
|
||||
client: DaemonClientV2 | null;
|
||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -104,7 +102,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
onPickImages,
|
||||
onRemoveImage,
|
||||
client,
|
||||
sendAgentAudio,
|
||||
placeholder = "Message...",
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
@@ -200,9 +197,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
retryFailedDictation,
|
||||
discardFailedDictation,
|
||||
} = useDictation({
|
||||
sendAgentAudio,
|
||||
client,
|
||||
mode: "transcribe_only",
|
||||
onTranscript: handleDictationTranscript,
|
||||
onError: handleDictationError,
|
||||
canStart: canStartDictation,
|
||||
@@ -538,8 +533,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
{leftContent}
|
||||
</View>
|
||||
|
||||
{/* Right: send/queue button, voice button, rightContent slot */}
|
||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||
<View style={styles.rightButtonGroup}>
|
||||
<Pressable
|
||||
onPress={handleVoicePress}
|
||||
disabled={!isConnected || disabled}
|
||||
style={[
|
||||
styles.voiceButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square size={14} color="white" fill="white" />
|
||||
) : (
|
||||
<Mic size={20} color={theme.colors.foreground} />
|
||||
)}
|
||||
</Pressable>
|
||||
{shouldShowSendButton && (
|
||||
<Pressable
|
||||
onPress={handleSendMessage}
|
||||
@@ -561,22 +571,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
<ArrowUp size={20} color="white" />
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={handleVoicePress}
|
||||
disabled={!isConnected || disabled}
|
||||
style={[
|
||||
styles.voiceButton,
|
||||
(!isConnected || disabled) && styles.buttonDisabled,
|
||||
isDictating && styles.voiceButtonRecording,
|
||||
]}
|
||||
>
|
||||
{isDictating ? (
|
||||
<Square size={14} color="white" fill="white" />
|
||||
) : (
|
||||
<Mic size={20} color={theme.colors.foreground} />
|
||||
)}
|
||||
</Pressable>
|
||||
{rightContent}
|
||||
{!shouldShowSendButton && rightContent}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
@@ -387,16 +387,21 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
detailWrapper: {
|
||||
marginTop: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderBottomLeftRadius: theme.borderRadius.lg,
|
||||
borderBottomRightRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderTopWidth: 0,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
pressableExpanded: {
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
function isLikelyPathToken(value: string): boolean {
|
||||
@@ -511,7 +516,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]} selectable>
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
@@ -525,7 +530,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.textgroup]}
|
||||
selectable
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -540,7 +544,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.code_block]}
|
||||
selectable
|
||||
>
|
||||
{node.content}
|
||||
</Text>
|
||||
@@ -552,7 +555,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]} selectable>
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
@@ -576,7 +579,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
inheritedStyles,
|
||||
assistantMessageStylesheet.markdownCodeInline,
|
||||
]}
|
||||
selectable
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
@@ -1153,6 +1155,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
pressed && hasDetails
|
||||
? expandableBadgeStylesheet.pressablePressed
|
||||
: null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
]}
|
||||
>
|
||||
<View style={expandableBadgeStylesheet.headerRow}>
|
||||
@@ -1181,12 +1184,12 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
{detailContent ? (
|
||||
<View style={expandableBadgeStylesheet.detailWrapper}>
|
||||
{detailContent}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{detailContent ? (
|
||||
<View style={expandableBadgeStylesheet.detailWrapper}>
|
||||
{detailContent}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -1226,7 +1229,7 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]} selectable>
|
||||
<Text key={node.key} style={[inheritedStyles, styles.text]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
@@ -1240,7 +1243,6 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.textgroup]}
|
||||
selectable
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -1255,7 +1257,6 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.code_block]}
|
||||
selectable
|
||||
>
|
||||
{node.content}
|
||||
</Text>
|
||||
@@ -1267,7 +1268,7 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||
styles: any,
|
||||
inheritedStyles: any = {}
|
||||
) => (
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]} selectable>
|
||||
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
|
||||
{node.content}
|
||||
</Text>
|
||||
),
|
||||
@@ -1281,7 +1282,6 @@ export const AgentThoughtMessage = memo(function AgentThoughtMessage({
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[inheritedStyles, styles.code_inline]}
|
||||
selectable
|
||||
>
|
||||
{node.content}
|
||||
</Text>
|
||||
@@ -1432,7 +1432,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
args !== undefined || result !== undefined || error !== undefined;
|
||||
|
||||
// Parse tool call details for inline rendering
|
||||
const { display, errorText } = useToolCallDetails({ args, result, error });
|
||||
const { display, errorText } = useToolCallDetails({ toolName, args, result, error });
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!isMobile && isPerfLoggingEnabled()) {
|
||||
@@ -1498,7 +1498,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="tool-call-badge"
|
||||
label={toolName}
|
||||
label={display.toolName}
|
||||
secondaryLabel={principalParam}
|
||||
icon={IconComponent}
|
||||
isExpanded={!isMobile && isExpanded}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { AgentList } from "./agent-list";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
const SIDEBAR_AGENT_LIMIT = 15;
|
||||
@@ -44,6 +45,8 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
} = useSidebarAnimation();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -249,8 +252,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.desktopSidebar, { width: DESKTOP_SIDEBAR_WIDTH }]}>
|
||||
{/* Header: New Agent button */}
|
||||
<View style={styles.sidebarHeader}>
|
||||
{/* Header: New Agent button - top padding area is draggable on Tauri */}
|
||||
<View
|
||||
style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<Pressable
|
||||
style={({ hovered }) => [
|
||||
styles.newAgentButton,
|
||||
@@ -316,6 +322,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
newAgentButton: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import {
|
||||
parseToolCallDisplay,
|
||||
buildLineDiff,
|
||||
parseUnifiedDiff,
|
||||
type ToolCallDisplay,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
import { DiffViewer } from "./diff-viewer";
|
||||
@@ -14,6 +15,7 @@ import { DiffViewer } from "./diff-viewer";
|
||||
// ---- Types ----
|
||||
|
||||
export interface ToolCallDetailsData {
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
@@ -139,6 +141,10 @@ export function ToolCallDetailsContent({
|
||||
// Compute diff lines for edit type
|
||||
const diffLines = useMemo(() => {
|
||||
if (display.type !== "edit") return undefined;
|
||||
// Use pre-computed unified diff if available (e.g., from apply_patch)
|
||||
if (display.unifiedDiff) {
|
||||
return parseUnifiedDiff(display.unifiedDiff);
|
||||
}
|
||||
return buildLineDiff(display.oldString, display.newString);
|
||||
}, [display]);
|
||||
|
||||
@@ -304,12 +310,12 @@ export function ToolCallDetailsContent({
|
||||
// ---- Hook for parsing tool call data ----
|
||||
|
||||
export function useToolCallDetails(data: ToolCallDetailsData) {
|
||||
const { args, result, error } = data;
|
||||
const { toolName, args, result, error } = data;
|
||||
|
||||
return useMemo(() => {
|
||||
const shouldLog = isPerfLoggingEnabled();
|
||||
const startMs = shouldLog ? getNowMs() : 0;
|
||||
const display = parseToolCallDisplay(args, result);
|
||||
const display = parseToolCallDisplay(toolName, args, result);
|
||||
const errorText = error !== undefined ? formatValue(error) : undefined;
|
||||
if (shouldLog) {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
@@ -329,7 +335,7 @@ export function useToolCallDetails(data: ToolCallDetailsData) {
|
||||
}
|
||||
}
|
||||
return { display, errorText };
|
||||
}, [args, result, error]);
|
||||
}, [toolName, args, result, error]);
|
||||
}
|
||||
|
||||
// ---- Styles ----
|
||||
|
||||
@@ -144,7 +144,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
? toolKindIcons[kind.toLowerCase()] || Wrench
|
||||
: Wrench;
|
||||
|
||||
const { display, errorText } = useToolCallDetails({ args, result, error });
|
||||
const { display, errorText } = useToolCallDetails({ toolName, args, result, error });
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -153,7 +153,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
<View style={styles.headerLeft}>
|
||||
<IconComponent size={20} color={styles.headerIcon.color} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{toolName}
|
||||
{display.toolName}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Platform } from "react-native";
|
||||
|
||||
export const FOOTER_HEIGHT = 75;
|
||||
|
||||
// Shared header inner height (excluding safe area insets and border)
|
||||
@@ -7,3 +9,43 @@ export const HEADER_INNER_HEIGHT = 48;
|
||||
|
||||
// Max width for chat content (stream view, input area, new agent form)
|
||||
export const MAX_CONTENT_WIDTH = 820;
|
||||
|
||||
// Tauri desktop app constants for macOS traffic light buttons
|
||||
// These buttons (close/minimize/maximize) overlay the top-left corner
|
||||
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
|
||||
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
|
||||
|
||||
// Check if running in Tauri desktop app on macOS
|
||||
function isTauriMac(): boolean {
|
||||
if (Platform.OS !== "web") return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
if (!("__TAURI__" in window)) return false;
|
||||
// Check for macOS via user agent
|
||||
const ua = navigator.userAgent;
|
||||
return ua.includes("Mac OS") || ua.includes("Macintosh");
|
||||
}
|
||||
|
||||
// Cached result - only cache true, keep checking if false (in case __TAURI__ loads later)
|
||||
let _isTauriMacCached: boolean | null = null;
|
||||
|
||||
export function getIsTauriMac(): boolean {
|
||||
if (_isTauriMacCached === true) {
|
||||
return true;
|
||||
}
|
||||
const result = isTauriMac();
|
||||
if (result) {
|
||||
_isTauriMacCached = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get traffic light padding values (only non-zero on Tauri macOS)
|
||||
export function getTrafficLightPadding(): { left: number; top: number } {
|
||||
if (!getIsTauriMac()) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
return {
|
||||
left: TAURI_TRAFFIC_LIGHT_WIDTH,
|
||||
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -352,12 +352,6 @@ export interface SessionContextValue {
|
||||
message: string,
|
||||
images?: Array<{ uri: string; mimeType?: string }>
|
||||
) => Promise<void>;
|
||||
sendAgentAudio: (
|
||||
agentId: string | undefined,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
) => Promise<void>;
|
||||
deleteAgent: (agentId: string) => void;
|
||||
createAgent: (options: {
|
||||
config: any;
|
||||
@@ -1972,69 +1966,6 @@ export function SessionProvider({
|
||||
[client]
|
||||
);
|
||||
|
||||
const sendAgentAudio = useCallback(
|
||||
async (
|
||||
agentId: string | undefined,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
) => {
|
||||
try {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
if (!client.isConnected) {
|
||||
throw new Error("Daemon client is disconnected");
|
||||
}
|
||||
const resolvedRequestId = requestId ?? generateMessageId();
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64Audio = btoa(binary);
|
||||
|
||||
const deriveFormat = (mimeType: string | undefined): string => {
|
||||
if (!mimeType || mimeType.length === 0) {
|
||||
return "webm";
|
||||
}
|
||||
const slashIndex = mimeType.indexOf("/");
|
||||
let formatPart =
|
||||
slashIndex >= 0 ? mimeType.slice(slashIndex + 1) : mimeType;
|
||||
const semicolonIndex = formatPart.indexOf(";");
|
||||
if (semicolonIndex >= 0) {
|
||||
formatPart = formatPart.slice(0, semicolonIndex);
|
||||
}
|
||||
return formatPart.trim().length > 0 ? formatPart.trim() : "webm";
|
||||
};
|
||||
|
||||
const format = deriveFormat(audioBlob.type);
|
||||
await client.sendAgentAudio({
|
||||
...(agentId ? { agentId } : {}),
|
||||
audio: base64Audio,
|
||||
format,
|
||||
isLast: true,
|
||||
requestId: resolvedRequestId,
|
||||
...(options?.mode ? { mode: options.mode } : {}),
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[Session] Sent audio:",
|
||||
agentId ?? "(no agent)",
|
||||
format,
|
||||
audioBlob.size,
|
||||
"bytes",
|
||||
`(requestId: ${resolvedRequestId})`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Session] Failed to send audio:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[client]
|
||||
);
|
||||
|
||||
const createAgent = useCallback(
|
||||
async ({
|
||||
config,
|
||||
@@ -2344,7 +2275,6 @@ export function SessionProvider({
|
||||
cancelAgentRun,
|
||||
deleteAgent,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
@@ -2368,7 +2298,6 @@ export function SessionProvider({
|
||||
cancelAgentRun,
|
||||
deleteAgent,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
@@ -2394,7 +2323,6 @@ export function SessionProvider({
|
||||
refreshSession,
|
||||
cancelAgentRun,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
deleteAgent,
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
@@ -2415,7 +2343,6 @@ export function SessionProvider({
|
||||
refreshSession,
|
||||
cancelAgentRun,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
deleteAgent,
|
||||
createAgent,
|
||||
setAgentMode,
|
||||
|
||||
@@ -8,20 +8,47 @@ import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { ProviderModelState } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFormPreferences } from "./use-form-preferences";
|
||||
import { useFormPreferences, type FormPreferences } from "./use-form-preferences";
|
||||
|
||||
export type CreateAgentInitialValues = {
|
||||
workingDir?: string;
|
||||
// Explicit overrides from URL params or "New Agent" button
|
||||
export interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
provider?: AgentProvider;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
// Tracks which fields the user has explicitly modified in this session
|
||||
interface UserModifiedFields {
|
||||
serverId: boolean;
|
||||
provider: boolean;
|
||||
modeId: boolean;
|
||||
model: boolean;
|
||||
workingDir: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
workingDir: false,
|
||||
};
|
||||
|
||||
// Internal form state
|
||||
interface FormState {
|
||||
serverId: string | null;
|
||||
provider: AgentProvider;
|
||||
modeId: string;
|
||||
model: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
type UseAgentFormStateOptions = {
|
||||
initialServerId?: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialValues?: FormInitialValues;
|
||||
isVisible?: boolean;
|
||||
isCreateFlow?: boolean;
|
||||
isTargetDaemonReady?: boolean;
|
||||
@@ -66,6 +93,106 @@ const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
|
||||
fallbackDefinition?.defaultModeId ?? "";
|
||||
|
||||
/**
|
||||
* Pure function that resolves form state from multiple data sources.
|
||||
* Priority: explicit (URL params) > preferences > provider defaults > fallback
|
||||
*
|
||||
* Only resolves fields that haven't been user-modified.
|
||||
*/
|
||||
function resolveFormState(
|
||||
initialValues: FormInitialValues | undefined,
|
||||
preferences: FormPreferences | null,
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
userModified: UserModifiedFields,
|
||||
currentState: FormState
|
||||
): FormState {
|
||||
// Start with current state - we only update non-user-modified fields
|
||||
const result = { ...currentState };
|
||||
|
||||
// 1. Resolve provider first (other fields depend on it)
|
||||
if (!userModified.provider) {
|
||||
if (initialValues?.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
result.provider = initialValues.provider;
|
||||
} else if (
|
||||
preferences?.provider &&
|
||||
providerDefinitionMap.has(preferences.provider as AgentProvider)
|
||||
) {
|
||||
result.provider = preferences.provider as AgentProvider;
|
||||
}
|
||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
||||
}
|
||||
|
||||
const providerDef = providerDefinitionMap.get(result.provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
||||
|
||||
// 2. Resolve modeId (depends on provider)
|
||||
if (!userModified.modeId) {
|
||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||
|
||||
if (
|
||||
typeof initialValues?.modeId === "string" &&
|
||||
initialValues.modeId.length > 0 &&
|
||||
validModeIds.includes(initialValues.modeId)
|
||||
) {
|
||||
result.modeId = initialValues.modeId;
|
||||
} else if (
|
||||
providerPrefs?.mode &&
|
||||
validModeIds.includes(providerPrefs.mode)
|
||||
) {
|
||||
result.modeId = providerPrefs.mode;
|
||||
} else {
|
||||
result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve model (depends on provider + availableModels)
|
||||
if (!userModified.model) {
|
||||
const isValidModel = (m: string) =>
|
||||
availableModels?.some((am) => am.id === m) ?? false;
|
||||
|
||||
if (
|
||||
typeof initialValues?.model === "string" &&
|
||||
initialValues.model.length > 0
|
||||
) {
|
||||
// If models aren't loaded yet, trust the initial value
|
||||
// It will be validated once models load
|
||||
if (!availableModels || isValidModel(initialValues.model)) {
|
||||
result.model = initialValues.model;
|
||||
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
} else if (providerPrefs?.model && isValidModel(providerPrefs.model)) {
|
||||
result.model = providerPrefs.model;
|
||||
} else {
|
||||
result.model = "";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve serverId (independent)
|
||||
if (!userModified.serverId) {
|
||||
if (initialValues?.serverId !== undefined) {
|
||||
result.serverId = initialValues.serverId;
|
||||
} else if (preferences?.serverId) {
|
||||
result.serverId = preferences.serverId;
|
||||
}
|
||||
// else keep current
|
||||
}
|
||||
|
||||
// 5. Resolve workingDir (independent)
|
||||
if (!userModified.workingDir) {
|
||||
if (initialValues?.workingDir !== undefined) {
|
||||
result.workingDir = initialValues.workingDir;
|
||||
} else if (preferences?.workingDir) {
|
||||
result.workingDir = preferences.workingDir;
|
||||
}
|
||||
// else keep current (empty string)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAgentFormState(
|
||||
options: UseAgentFormStateOptions = {}
|
||||
): UseAgentFormStateResult {
|
||||
@@ -80,29 +207,36 @@ export function useAgentFormState(
|
||||
const {
|
||||
preferences,
|
||||
isLoading: isPreferencesLoading,
|
||||
getProviderPreferences,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
} = useFormPreferences();
|
||||
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(
|
||||
initialServerId
|
||||
);
|
||||
const [workingDir, setWorkingDir] = useState("");
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
useState<AgentProvider>(DEFAULT_PROVIDER);
|
||||
const [selectedMode, setSelectedMode] = useState(
|
||||
DEFAULT_MODE_FOR_DEFAULT_PROVIDER
|
||||
);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
// Track which fields the user has explicitly modified
|
||||
const [userModified, setUserModified] = useState<UserModifiedFields>(INITIAL_USER_MODIFIED);
|
||||
|
||||
const hasHydratedRef = useRef(false);
|
||||
const hasAppliedInitialValuesRef = useRef(false);
|
||||
const providerModelRequestTimersRef = useRef<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
>(new Map());
|
||||
// Form state
|
||||
const [formState, setFormState] = useState<FormState>(() => ({
|
||||
serverId: initialServerId,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
|
||||
model: "",
|
||||
workingDir: "",
|
||||
}));
|
||||
|
||||
// Track if we've done initial resolution (to avoid flickering)
|
||||
const hasResolvedRef = useRef(false);
|
||||
|
||||
// Reset user modifications when form becomes invisible
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
setUserModified(INITIAL_USER_MODIFIED);
|
||||
hasResolvedRef.current = false;
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
// Get session state for provider models
|
||||
const sessionState = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId] : undefined
|
||||
formState.serverId ? state.sessions[formState.serverId] : undefined
|
||||
);
|
||||
const providerModels = sessionState?.providerModels;
|
||||
const requestProviderModels = sessionState?.methods?.requestProviderModels;
|
||||
@@ -111,9 +245,73 @@ export function useAgentFormState(
|
||||
[]
|
||||
);
|
||||
|
||||
// Get available models for current provider
|
||||
const modelState = providerModels?.get(formState.provider);
|
||||
const availableModels = modelState?.models ?? null;
|
||||
|
||||
// Combine initialValues with initialServerId for resolution
|
||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||
if (!initialValues && initialServerId === null) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...initialValues,
|
||||
serverId: initialValues?.serverId ?? initialServerId,
|
||||
};
|
||||
}, [initialValues, initialServerId]);
|
||||
|
||||
// Resolve form state when data sources change
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isCreateFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for preferences to load before first resolution
|
||||
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = resolveFormState(
|
||||
combinedInitialValues,
|
||||
preferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formState
|
||||
);
|
||||
|
||||
// Only update if something changed
|
||||
if (
|
||||
resolved.serverId !== formState.serverId ||
|
||||
resolved.provider !== formState.provider ||
|
||||
resolved.modeId !== formState.modeId ||
|
||||
resolved.model !== formState.model ||
|
||||
resolved.workingDir !== formState.workingDir
|
||||
) {
|
||||
setFormState(resolved);
|
||||
}
|
||||
|
||||
hasResolvedRef.current = true;
|
||||
}, [
|
||||
isVisible,
|
||||
isCreateFlow,
|
||||
isPreferencesLoading,
|
||||
combinedInitialValues,
|
||||
preferences,
|
||||
availableModels,
|
||||
userModified,
|
||||
formState,
|
||||
]);
|
||||
|
||||
// Provider model request timers
|
||||
const providerModelRequestTimersRef = useRef<
|
||||
Map<string, ReturnType<typeof setTimeout>>
|
||||
>(new Map());
|
||||
|
||||
// User setters - mark fields as modified and persist to preferences
|
||||
const setSelectedServerIdFromUser = useCallback(
|
||||
(value: string | null) => {
|
||||
setSelectedServerId(value);
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
setUserModified((prev) => ({ ...prev, serverId: true }));
|
||||
void updatePreferences({ serverId: value ?? undefined });
|
||||
},
|
||||
[updatePreferences]
|
||||
@@ -121,124 +319,86 @@ export function useAgentFormState(
|
||||
|
||||
const setProviderFromUser = useCallback(
|
||||
(provider: AgentProvider) => {
|
||||
setSelectedProvider(provider);
|
||||
setFormState((prev) => ({ ...prev, provider }));
|
||||
setUserModified((prev) => ({ ...prev, provider: true }));
|
||||
void updatePreferences({ provider });
|
||||
|
||||
// Restore per-provider preferences if available
|
||||
const providerPrefs = getProviderPreferences(provider);
|
||||
// When provider changes, reset mode and model to provider defaults
|
||||
// (unless user has explicitly set them)
|
||||
const providerDef = providerDefinitionMap.get(provider);
|
||||
const providerPrefs = preferences?.providerPreferences?.[provider];
|
||||
|
||||
setSelectedModel(providerPrefs?.model ?? "");
|
||||
setSelectedMode(providerPrefs?.mode ?? providerDef?.defaultModeId ?? "");
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
provider,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
model: providerPrefs?.model ?? "",
|
||||
}));
|
||||
},
|
||||
[getProviderPreferences, updatePreferences]
|
||||
[preferences?.providerPreferences, updatePreferences]
|
||||
);
|
||||
|
||||
const setModeFromUser = useCallback(
|
||||
(modeId: string) => {
|
||||
setSelectedMode(modeId);
|
||||
void updateProviderPreferences(selectedProvider, { mode: modeId });
|
||||
setFormState((prev) => ({ ...prev, modeId }));
|
||||
setUserModified((prev) => ({ ...prev, modeId: true }));
|
||||
void updateProviderPreferences(formState.provider, { mode: modeId });
|
||||
},
|
||||
[selectedProvider, updateProviderPreferences]
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
|
||||
const setModelFromUser = useCallback(
|
||||
(modelId: string) => {
|
||||
setSelectedModel(modelId);
|
||||
void updateProviderPreferences(selectedProvider, { model: modelId });
|
||||
setFormState((prev) => ({ ...prev, model: modelId }));
|
||||
setUserModified((prev) => ({ ...prev, model: true }));
|
||||
void updateProviderPreferences(formState.provider, { model: modelId });
|
||||
},
|
||||
[selectedProvider, updateProviderPreferences]
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
}, []);
|
||||
|
||||
const setWorkingDirFromUser = useCallback(
|
||||
(value: string) => {
|
||||
setWorkingDir(value);
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
setUserModified((prev) => ({ ...prev, workingDir: true }));
|
||||
void updatePreferences({ workingDir: value });
|
||||
},
|
||||
[updatePreferences]
|
||||
);
|
||||
|
||||
const applyInitialValues = useCallback(() => {
|
||||
if (!isCreateFlow || !initialValues) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(initialValues, "workingDir")) {
|
||||
setWorkingDir(initialValues.workingDir ?? "");
|
||||
}
|
||||
|
||||
if (initialValues.provider && providerDefinitionMap.has(initialValues.provider)) {
|
||||
setSelectedProvider(initialValues.provider);
|
||||
}
|
||||
|
||||
if (typeof initialValues.modeId === "string" && initialValues.modeId.length > 0) {
|
||||
setSelectedMode(initialValues.modeId);
|
||||
}
|
||||
|
||||
if (typeof initialValues.model === "string" && initialValues.model.length > 0) {
|
||||
setSelectedModel(initialValues.model);
|
||||
}
|
||||
}, [initialValues, isCreateFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
hasAppliedInitialValuesRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (hasAppliedInitialValuesRef.current) {
|
||||
return;
|
||||
}
|
||||
applyInitialValues();
|
||||
hasAppliedInitialValuesRef.current = true;
|
||||
}, [applyInitialValues, isVisible]);
|
||||
const setSelectedServerId = useCallback((value: string | null) => {
|
||||
setFormState((prev) => ({ ...prev, serverId: value }));
|
||||
}, []);
|
||||
|
||||
const refreshProviderModels = useCallback(() => {
|
||||
if (!requestProviderModels) {
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
requestProviderModels(selectedProvider, {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
requestProviderModels(formState.provider, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
});
|
||||
}, [requestProviderModels, selectedProvider, workingDir]);
|
||||
|
||||
// Hydrate form state from preferences once loaded
|
||||
useEffect(() => {
|
||||
if (isPreferencesLoading || hasHydratedRef.current) return;
|
||||
hasHydratedRef.current = true;
|
||||
|
||||
const activeProvider =
|
||||
preferences.provider &&
|
||||
providerDefinitionMap.has(preferences.provider as AgentProvider)
|
||||
? (preferences.provider as AgentProvider)
|
||||
: DEFAULT_PROVIDER;
|
||||
|
||||
const providerPrefs = preferences.providerPreferences?.[activeProvider];
|
||||
const providerDef = providerDefinitionMap.get(activeProvider);
|
||||
|
||||
setSelectedProvider(activeProvider);
|
||||
if (preferences.workingDir) setWorkingDir(preferences.workingDir);
|
||||
setSelectedMode(providerPrefs?.mode ?? providerDef?.defaultModeId ?? "");
|
||||
if (providerPrefs?.model) setSelectedModel(providerPrefs.model);
|
||||
if (preferences.serverId) setSelectedServerId(preferences.serverId);
|
||||
}, [isPreferencesLoading, preferences]);
|
||||
}, [requestProviderModels, formState.provider, formState.workingDir]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
await updatePreferences({
|
||||
workingDir,
|
||||
provider: selectedProvider,
|
||||
serverId: selectedServerId ?? undefined,
|
||||
workingDir: formState.workingDir,
|
||||
provider: formState.provider,
|
||||
serverId: formState.serverId ?? undefined,
|
||||
});
|
||||
await updateProviderPreferences(selectedProvider, {
|
||||
mode: selectedMode,
|
||||
model: selectedModel,
|
||||
await updateProviderPreferences(formState.provider, {
|
||||
mode: formState.modeId,
|
||||
model: formState.model,
|
||||
});
|
||||
}, [
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
workingDir,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
formState.provider,
|
||||
formState.serverId,
|
||||
formState.workingDir,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
]);
|
||||
@@ -270,7 +430,7 @@ export function useAgentFormState(
|
||||
return;
|
||||
}
|
||||
|
||||
const currentState = sessionState.providerModels?.get(selectedProvider);
|
||||
const currentState = sessionState.providerModels?.get(formState.provider);
|
||||
if (currentState?.models?.length || currentState?.isLoading) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
@@ -279,7 +439,7 @@ export function useAgentFormState(
|
||||
const delayMs = options?.delayMs ?? 0;
|
||||
const trigger = () => {
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
sessionState.methods?.requestProviderModels(selectedProvider, {
|
||||
sessionState.methods?.requestProviderModels(formState.provider, {
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
});
|
||||
};
|
||||
@@ -290,7 +450,7 @@ export function useAgentFormState(
|
||||
trigger();
|
||||
}
|
||||
},
|
||||
[clearQueuedProviderModelRequest, getSessionState, selectedProvider]
|
||||
[clearQueuedProviderModelRequest, getSessionState, formState.provider]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -303,74 +463,53 @@ export function useAgentFormState(
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isTargetDaemonReady || !selectedServerId) {
|
||||
clearQueuedProviderModelRequest(selectedServerId);
|
||||
if (!isVisible || !isTargetDaemonReady || !formState.serverId) {
|
||||
clearQueuedProviderModelRequest(formState.serverId);
|
||||
return;
|
||||
}
|
||||
const trimmed = workingDir.trim();
|
||||
queueProviderModelFetch(selectedServerId, {
|
||||
const trimmed = formState.workingDir.trim();
|
||||
queueProviderModelFetch(formState.serverId, {
|
||||
cwd: trimmed.length > 0 ? trimmed : undefined,
|
||||
delayMs: 180,
|
||||
});
|
||||
return () => {
|
||||
clearQueuedProviderModelRequest(selectedServerId);
|
||||
clearQueuedProviderModelRequest(formState.serverId);
|
||||
};
|
||||
}, [
|
||||
clearQueuedProviderModelRequest,
|
||||
isTargetDaemonReady,
|
||||
isVisible,
|
||||
queueProviderModelFetch,
|
||||
selectedServerId,
|
||||
workingDir,
|
||||
formState.serverId,
|
||||
formState.workingDir,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(selectedProvider);
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const modelState = providerModels?.get(selectedProvider);
|
||||
const availableModels = modelState?.models ?? [];
|
||||
const isModelLoading = modelState?.isLoading ?? false;
|
||||
const modelError = modelState?.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeOptions.length === 0) {
|
||||
if (selectedMode !== "") {
|
||||
setSelectedMode("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const availableModeIds = modeOptions.map((mode) => mode.id);
|
||||
if (!availableModeIds.includes(selectedMode)) {
|
||||
const fallbackModeId = agentDefinition.defaultModeId ?? availableModeIds[0];
|
||||
setSelectedMode(fallbackModeId);
|
||||
}
|
||||
}, [agentDefinition, modeOptions, selectedMode]);
|
||||
|
||||
const workingDirIsEmpty = !workingDir.trim();
|
||||
const workingDirIsEmpty = !formState.workingDir.trim();
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
selectedServerId,
|
||||
selectedServerId: formState.serverId,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
selectedProvider,
|
||||
selectedProvider: formState.provider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
selectedModel: formState.model,
|
||||
setModelFromUser,
|
||||
workingDir,
|
||||
workingDir: formState.workingDir,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
providerDefinitionMap,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
availableModels: availableModels ?? [],
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
@@ -380,26 +519,31 @@ export function useAgentFormState(
|
||||
persistFormPreferences,
|
||||
}),
|
||||
[
|
||||
agentDefinition,
|
||||
availableModels,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
modeOptions,
|
||||
queueProviderModelFetch,
|
||||
clearQueuedProviderModelRequest,
|
||||
refreshProviderModels,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
formState.serverId,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
formState.workingDir,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setProviderFromUser,
|
||||
setModeFromUser,
|
||||
setModelFromUser,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
workingDir,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
queueProviderModelFetch,
|
||||
clearQueuedProviderModelRequest,
|
||||
workingDirIsEmpty,
|
||||
persistFormPreferences,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type CreateAgentInitialValues = FormInitialValues;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { TranscriptionResultMessage } from "@server/shared/messages";
|
||||
@@ -33,10 +32,7 @@ export type DictationOutcome =
|
||||
| { type: "failure"; requestId: string; errorMessage: string; timestamp: number };
|
||||
|
||||
export type UseDictationOptions = {
|
||||
agentId?: string;
|
||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||
client: DaemonClientV2 | null;
|
||||
mode?: "transcribe_only" | "auto_run";
|
||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onRetryAttempt?: (info: DictationRetryInfo) => void;
|
||||
@@ -121,6 +117,16 @@ type CapturedAudioPayload = {
|
||||
|
||||
type TranscriptionPayload = TranscriptionResultMessage["payload"];
|
||||
|
||||
const blobToBase64 = async (blob: Blob): Promise<string> => {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
const deriveFormatFromMime = (mimeType?: string): string => {
|
||||
if (!mimeType || mimeType.length === 0) {
|
||||
return "webm";
|
||||
@@ -144,10 +150,7 @@ const buildCapturedAudioPayload = (blob: Blob, durationSeconds: number): Capture
|
||||
|
||||
export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const {
|
||||
agentId,
|
||||
sendAgentAudio,
|
||||
client,
|
||||
mode = "transcribe_only",
|
||||
onTranscript,
|
||||
onError,
|
||||
onRetryAttempt,
|
||||
@@ -172,7 +175,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const maxRetryAttempts = MAX_AUTO_RETRY_ATTEMPTS;
|
||||
|
||||
const transcriptionMutation = useMutation({
|
||||
mutationFn: async (requestId: string): Promise<TranscriptionPayload> => {
|
||||
mutationFn: async (): Promise<TranscriptionPayload> => {
|
||||
const capturedAudio = pendingAudioRef.current;
|
||||
if (!capturedAudio) {
|
||||
throw new Error("No recorded audio available for transcription");
|
||||
@@ -199,9 +202,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
}
|
||||
|
||||
console.info("[useDictation] sending transcription request", {
|
||||
requestId,
|
||||
attempt,
|
||||
agentId,
|
||||
size: capturedAudio.sizeBytes,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
});
|
||||
@@ -210,20 +211,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryAttempt(attempt);
|
||||
|
||||
try {
|
||||
const transcriptionPromise = client.waitForTranscriptionResult(
|
||||
requestId,
|
||||
TRANSCRIPTION_TIMEOUT_MS
|
||||
);
|
||||
try {
|
||||
await sendAgentAudio(agentId, capturedAudio.blob, requestId, {
|
||||
mode,
|
||||
});
|
||||
} catch (error) {
|
||||
transcriptionPromise.catch(() => {});
|
||||
throw new DictationAttemptError("dispatch", toError(error));
|
||||
}
|
||||
|
||||
return await transcriptionPromise;
|
||||
const base64Audio = await blobToBase64(capturedAudio.blob);
|
||||
return await client.transcribeAudio({
|
||||
audio: base64Audio,
|
||||
format: capturedAudio.format,
|
||||
timeout: TRANSCRIPTION_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
const normalized = toError(error);
|
||||
const reason: DictationRetryReason = isTimeoutError(normalized)
|
||||
@@ -256,7 +249,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryInfo(info);
|
||||
onRetryAttemptRef.current?.(info);
|
||||
console.warn("[useDictation] retry scheduled", {
|
||||
requestId,
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: MAX_AUTO_RETRY_ATTEMPTS,
|
||||
reason: attemptError.reason,
|
||||
@@ -383,12 +375,13 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
}, []);
|
||||
|
||||
const transmitDictation = useCallback(
|
||||
async (requestId: string) => runTranscription(requestId),
|
||||
async () => runTranscription(),
|
||||
[runTranscription]
|
||||
);
|
||||
|
||||
const handleTranscriptionSuccess = useCallback(
|
||||
(transcription: TranscriptionPayload, requestId: string) => {
|
||||
(transcription: TranscriptionPayload) => {
|
||||
const requestId = transcription.requestId;
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
@@ -409,16 +402,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
requestId,
|
||||
textLength: transcriptText.length,
|
||||
});
|
||||
onTranscriptRef.current?.(transcriptText, {
|
||||
requestId: transcription.requestId ?? requestId,
|
||||
});
|
||||
onTranscriptRef.current?.(transcriptText, { requestId });
|
||||
},
|
||||
[onTranscriptRef]
|
||||
);
|
||||
|
||||
const handleDictationFailure = useCallback(
|
||||
(failure: unknown, requestId: string | null) => {
|
||||
(failure: unknown) => {
|
||||
const normalized = toError(failure);
|
||||
const failureId = generateMessageId();
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
@@ -431,16 +423,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
if (capturedAudio) {
|
||||
setStatus("failed");
|
||||
setFailedRecording({
|
||||
requestId: requestId ?? generateMessageId(),
|
||||
requestId: failureId,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
sizeBytes: capturedAudio.sizeBytes,
|
||||
format: capturedAudio.format,
|
||||
recordedAt: capturedAudio.recordedAt,
|
||||
errorMessage: normalized.message,
|
||||
});
|
||||
if (requestId) {
|
||||
onPermanentFailureRef.current?.(normalized, { requestId });
|
||||
}
|
||||
onPermanentFailureRef.current?.(normalized, { requestId: failureId });
|
||||
} else {
|
||||
setStatus("idle");
|
||||
}
|
||||
@@ -448,7 +438,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryAttempt(0);
|
||||
setLastOutcome({
|
||||
type: "failure",
|
||||
requestId: requestId ?? generateMessageId(),
|
||||
requestId: failureId,
|
||||
errorMessage: normalized.message,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
@@ -571,7 +561,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryAttempt(0);
|
||||
setLastOutcome(null);
|
||||
|
||||
let requestId: string | null = null;
|
||||
try {
|
||||
const audioData = await stopRecorder();
|
||||
const recordedDurationSeconds = durationRef.current;
|
||||
@@ -581,21 +570,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setIsRecording(false);
|
||||
setVolume(0);
|
||||
|
||||
requestId = generateMessageId();
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setPendingRequestId(requestId);
|
||||
|
||||
const transcription = await transmitDictation(requestId);
|
||||
handleTranscriptionSuccess(transcription, requestId);
|
||||
const transcription = await transmitDictation();
|
||||
handleTranscriptionSuccess(transcription);
|
||||
} catch (err) {
|
||||
resetTranscriptionMutation();
|
||||
handleDictationFailure(err, requestId);
|
||||
handleDictationFailure(err);
|
||||
}
|
||||
}, [
|
||||
agentId,
|
||||
canConfirm,
|
||||
isProcessing,
|
||||
mode,
|
||||
handleDictationFailure,
|
||||
handleTranscriptionSuccess,
|
||||
resetTranscriptionMutation,
|
||||
@@ -615,16 +598,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setIsProcessing(true);
|
||||
setLastOutcome(null);
|
||||
|
||||
const requestId = generateMessageId();
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setPendingRequestId(requestId);
|
||||
|
||||
try {
|
||||
const transcription = await transmitDictation(requestId);
|
||||
handleTranscriptionSuccess(transcription, requestId);
|
||||
const transcription = await transmitDictation();
|
||||
handleTranscriptionSuccess(transcription);
|
||||
} catch (err) {
|
||||
resetTranscriptionMutation();
|
||||
handleDictationFailure(err, requestId);
|
||||
handleDictationFailure(err);
|
||||
}
|
||||
}, [
|
||||
handleDictationFailure,
|
||||
@@ -687,17 +666,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
}
|
||||
}, [autoStopWhenHidden?.isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
pendingAudioRef.current = null;
|
||||
setStatus("idle");
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
setFailedRecording(null);
|
||||
resetTranscriptionMutation();
|
||||
}, [agentId, resetTranscriptionMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ interface DesktopSidebarState {
|
||||
fileExplorerOpen: boolean;
|
||||
}
|
||||
|
||||
type ExplorerTab = "changes" | "files";
|
||||
export type ExplorerTab = "changes" | "files";
|
||||
export type ViewMode = "list" | "grid";
|
||||
export type SortOption = "name" | "modified" | "size";
|
||||
|
||||
|
||||
@@ -182,12 +182,6 @@ export interface SessionState {
|
||||
message: string,
|
||||
images?: Array<{ uri: string; mimeType?: string }>
|
||||
) => Promise<void>;
|
||||
sendAgentAudio: (
|
||||
agentId: string | undefined,
|
||||
audioBlob: Blob,
|
||||
requestId?: string,
|
||||
options?: { mode?: "transcribe_only" | "auto_run" }
|
||||
) => Promise<void>;
|
||||
deleteAgent: (agentId: string) => void;
|
||||
createAgent: (options: {
|
||||
config: any;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shortens a file path by replacing the home directory prefix with ~.
|
||||
* Handles both macOS (/Users/username) and Linux (/home/username) paths.
|
||||
*/
|
||||
export function shortenPath(path: string | undefined | null): string {
|
||||
if (!path) {
|
||||
return "";
|
||||
}
|
||||
return path.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Platform } from "react-native";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getIsTauriMac, TAURI_TRAFFIC_LIGHT_WIDTH, TAURI_TRAFFIC_LIGHT_HEIGHT } from "@/constants/layout";
|
||||
|
||||
let tauriWindow: any = null;
|
||||
|
||||
async function getTauriWindow() {
|
||||
if (tauriWindow) return tauriWindow;
|
||||
if (!getIsTauriMac()) return null;
|
||||
|
||||
try {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window");
|
||||
tauriWindow = getCurrentWindow();
|
||||
return tauriWindow;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDragging() {
|
||||
const win = await getTauriWindow();
|
||||
if (win) {
|
||||
await win.startDragging();
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMaximize() {
|
||||
const win = await getTauriWindow();
|
||||
if (win) {
|
||||
await win.toggleMaximize();
|
||||
}
|
||||
}
|
||||
|
||||
// Returns event handlers for drag region behavior
|
||||
export function useTauriDragHandlers() {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
onMouseDown: (e: React.MouseEvent) => {
|
||||
// Only handle left click, ignore if clicking on interactive elements
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button, a, input, [role='button']")) return;
|
||||
|
||||
// Prevent text selection when dragging
|
||||
e.preventDefault();
|
||||
|
||||
// Start dragging on mouse down
|
||||
startDragging();
|
||||
},
|
||||
onDoubleClick: (e: React.MouseEvent) => {
|
||||
// Ignore if clicking on interactive elements
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("button, a, input, [role='button']")) return;
|
||||
|
||||
e.preventDefault();
|
||||
toggleMaximize();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Hook that returns traffic light padding, accounting for fullscreen state
|
||||
// In fullscreen, traffic lights are hidden so no padding is needed
|
||||
export function useTrafficLightPadding(): { left: number; top: number } {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web" || !getIsTauriMac()) return;
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
async function setup() {
|
||||
const win = await getTauriWindow();
|
||||
if (!win) return;
|
||||
|
||||
// Check initial fullscreen state
|
||||
const fullscreen = await win.isFullscreen();
|
||||
setIsFullscreen(fullscreen);
|
||||
|
||||
// Listen for resize events which include fullscreen changes
|
||||
unlisten = await win.onResized(async () => {
|
||||
const fs = await win.isFullscreen();
|
||||
setIsFullscreen(fs);
|
||||
});
|
||||
}
|
||||
|
||||
setup();
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!getIsTauriMac() || isFullscreen) {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
left: TAURI_TRAFFIC_LIGHT_WIDTH,
|
||||
top: TAURI_TRAFFIC_LIGHT_HEIGHT,
|
||||
};
|
||||
}
|
||||
@@ -42,8 +42,9 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { command: "pwd", description: "Print working directory" };
|
||||
const result = { type: "command", command: "pwd", output: "/some/path" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Bash", input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
expect(display.toolName).toBe("Shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("pwd");
|
||||
expect(display.output).toBe("/some/path");
|
||||
@@ -55,8 +56,9 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { command: "pwd", description: "Print working directory" };
|
||||
const result = undefined;
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Bash", input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
expect(display.toolName).toBe("Shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("pwd");
|
||||
expect(display.output).toBe("");
|
||||
@@ -67,19 +69,44 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { command: ["git", "status"] };
|
||||
const result = { type: "command", output: "On branch main" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("shell", input, result);
|
||||
expect(display.type).toBe("shell");
|
||||
expect(display.toolName).toBe("Shell");
|
||||
if (display.type === "shell") {
|
||||
expect(display.command).toBe("git status");
|
||||
expect(display.output).toBe("On branch main");
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes tool names - shell to Shell", () => {
|
||||
const input = { command: "pwd" };
|
||||
const display = parseToolCallDisplay("shell", input, undefined);
|
||||
expect(display.toolName).toBe("Shell");
|
||||
});
|
||||
|
||||
test("normalizes tool names - Bash to Shell", () => {
|
||||
const input = { command: "pwd" };
|
||||
const display = parseToolCallDisplay("Bash", input, undefined);
|
||||
expect(display.toolName).toBe("Shell");
|
||||
});
|
||||
|
||||
test("normalizes tool names - read_file to Read", () => {
|
||||
const input = { file_path: "/some/file.txt" };
|
||||
const display = parseToolCallDisplay("read_file", input, undefined);
|
||||
expect(display.toolName).toBe("Read");
|
||||
});
|
||||
|
||||
test("preserves unknown tool names", () => {
|
||||
const input = { some_arg: "value" };
|
||||
const display = parseToolCallDisplay("MyCustomTool", input, undefined);
|
||||
expect(display.toolName).toBe("MyCustomTool");
|
||||
});
|
||||
|
||||
test("parses non-command tool call into generic type", () => {
|
||||
const input = { file_path: "/some/file.txt" };
|
||||
const result = { content: "file contents here", lineCount: 42 };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("SomeTool", input, result);
|
||||
expect(display.type).toBe("generic");
|
||||
if (display.type === "generic") {
|
||||
expect(display.input).toContainEqual({ key: "file_path", value: "/some/file.txt" });
|
||||
@@ -92,12 +119,12 @@ describe("parseToolCallDisplay", () => {
|
||||
const input = { file_path: "/some/file.txt", content: "new content" };
|
||||
const result = { type: "file_write", filePath: "/some/file.txt" };
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Write", input, result);
|
||||
expect(display.type).toBe("generic");
|
||||
});
|
||||
|
||||
test("handles undefined input and result gracefully", () => {
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(undefined, undefined);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("unknown", undefined, undefined);
|
||||
expect(display.type).toBe("generic");
|
||||
if (display.type === "generic") {
|
||||
expect(display.input).toEqual([]);
|
||||
@@ -116,7 +143,7 @@ describe("parseToolCallDisplay", () => {
|
||||
filePath: "/some/file.txt",
|
||||
};
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
@@ -133,7 +160,7 @@ describe("parseToolCallDisplay", () => {
|
||||
};
|
||||
const result = undefined;
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, result);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
@@ -149,7 +176,7 @@ describe("parseToolCallDisplay", () => {
|
||||
new_string: "new content",
|
||||
};
|
||||
|
||||
const display: ToolCallDisplay = parseToolCallDisplay(input, undefined);
|
||||
const display: ToolCallDisplay = parseToolCallDisplay("Edit", input, undefined);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
@@ -158,3 +185,111 @@ describe("parseToolCallDisplay", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolCallDisplay - apply_patch (Codex)", () => {
|
||||
test("parses apply_patch into edit type with unified diff", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
kind: "update",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
patch: "@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**",
|
||||
kind: "update",
|
||||
},
|
||||
],
|
||||
message: "Success. Updated the following files:\nM .tasks/578561c8.md",
|
||||
success: true,
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
expect(display.toolName).toBe("Edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md");
|
||||
expect(display.unifiedDiff).toBe("@@ -15,3 +15,2 @@\n-This task defines the **design philosophy**\n+This task defines the **updated philosophy**");
|
||||
expect(display.oldString).toBe("");
|
||||
expect(display.newString).toBe("");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses pending apply_patch (no result yet)", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/some/file.txt",
|
||||
kind: "create",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, undefined);
|
||||
expect(display.type).toBe("edit");
|
||||
expect(display.toolName).toBe("Edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/file.txt");
|
||||
expect(display.unifiedDiff).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("handles apply_patch with multiple files (uses first file)", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{ path: "/first/file.txt", kind: "update" },
|
||||
{ path: "/second/file.txt", kind: "create" },
|
||||
],
|
||||
};
|
||||
const result = {
|
||||
files: [
|
||||
{ path: "/first/file.txt", patch: "@@ -1 +1 @@\n-old\n+new", kind: "update" },
|
||||
{ path: "/second/file.txt", patch: "@@ -0,0 +1 @@\n+content", kind: "create" },
|
||||
],
|
||||
success: true,
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/first/file.txt");
|
||||
expect(display.unifiedDiff).toBe("@@ -1 +1 @@\n-old\n+new");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseToolCallDisplay - read_file (Codex)", () => {
|
||||
test("parses Codex read_file into read type", () => {
|
||||
const input = {
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
};
|
||||
const result = {
|
||||
type: "read_file",
|
||||
path: "/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md",
|
||||
content: "260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`",
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("read_file", input, result);
|
||||
expect(display.type).toBe("read");
|
||||
expect(display.toolName).toBe("Read");
|
||||
if (display.type === "read") {
|
||||
expect(display.filePath).toBe("/Users/moboudra/dev/blankpage/editor/.tasks/578561c8.md");
|
||||
expect(display.content).toBe("260 - Source: `**bold**|`\n261 - Action: `Shift+ArrowLeft`");
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex read_file falls through to generic when result is missing", () => {
|
||||
const input = {
|
||||
path: "/some/file.txt",
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("read_file", input, undefined);
|
||||
// Without result, it can't match the schema so falls through to generic
|
||||
expect(display.type).toBe("generic");
|
||||
expect(display.toolName).toBe("Read");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,7 +270,7 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
|
||||
return diff;
|
||||
}
|
||||
|
||||
function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
export function parseUnifiedDiff(diffText?: string): DiffLine[] {
|
||||
if (!diffText) {
|
||||
return [];
|
||||
}
|
||||
@@ -1023,7 +1023,7 @@ const EditToolCallSchema = z
|
||||
input: EditInputSchema,
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string } => {
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
|
||||
const filePath = data.input.file_path;
|
||||
const oldString = "old_string" in data.input
|
||||
? (data.input as { old_string: string }).old_string
|
||||
@@ -1040,6 +1040,52 @@ const EditToolCallSchema = z
|
||||
};
|
||||
});
|
||||
|
||||
// Codex apply_patch input: { files: [{ path: string, kind: string }] }
|
||||
const ApplyPatchInputSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
kind: z.string().optional(),
|
||||
})).min(1),
|
||||
}).passthrough();
|
||||
|
||||
// Codex apply_patch result: { files: [{ path: string, patch: string, kind: string }], message: string, success: boolean }
|
||||
const ApplyPatchResultSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
patch: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
})).optional(),
|
||||
message: z.string().optional(),
|
||||
success: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
// Apply patch tool call display schema - transforms to edit type with unified diff
|
||||
const ApplyPatchToolCallSchema = z
|
||||
.object({
|
||||
input: ApplyPatchInputSchema,
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
|
||||
const firstFile = data.input.files[0];
|
||||
const filePath = firstFile.path;
|
||||
|
||||
// Try to get the patch from the result
|
||||
const resultParsed = ApplyPatchResultSchema.safeParse(data.result);
|
||||
let unifiedDiff: string | undefined;
|
||||
if (resultParsed.success && resultParsed.data.files) {
|
||||
const resultFile = resultParsed.data.files.find(f => f.path === filePath) ?? resultParsed.data.files[0];
|
||||
unifiedDiff = resultFile?.patch;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
unifiedDiff,
|
||||
};
|
||||
});
|
||||
|
||||
// Read input: { file_path: string, offset?: number, limit?: number }
|
||||
const ReadInputSchema = z.object({
|
||||
file_path: z.string(),
|
||||
@@ -1054,7 +1100,7 @@ const ReadResultSchema = z.object({
|
||||
content: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Read tool call display schema
|
||||
// Read tool call display schema (Claude)
|
||||
const ReadToolCallSchema = z
|
||||
.object({
|
||||
input: ReadInputSchema,
|
||||
@@ -1068,6 +1114,30 @@ const ReadToolCallSchema = z
|
||||
limit: data.input.limit,
|
||||
}));
|
||||
|
||||
// Codex read_file input: { path: string }
|
||||
const CodexReadInputSchema = z.object({
|
||||
path: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Codex read_file result: { type: "read_file", path: string, content: string }
|
||||
const CodexReadResultSchema = z.object({
|
||||
type: z.literal("read_file"),
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
// Codex read_file tool call display schema
|
||||
const CodexReadToolCallSchema = z
|
||||
.object({
|
||||
input: CodexReadInputSchema,
|
||||
result: CodexReadResultSchema,
|
||||
})
|
||||
.transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({
|
||||
type: "read",
|
||||
filePath: data.input.path,
|
||||
content: data.result.content,
|
||||
}));
|
||||
|
||||
// Generic tool call display schema (fallback)
|
||||
const GenericToolCallSchema = z
|
||||
.object({
|
||||
@@ -1085,12 +1155,60 @@ const GenericToolCallSchema = z
|
||||
};
|
||||
});
|
||||
|
||||
const ToolCallDisplaySchema = z.union([ShellToolCallSchema, EditToolCallSchema, ReadToolCallSchema, GenericToolCallSchema]);
|
||||
// Normalizes tool names for consistent display across agents
|
||||
const TOOL_NAME_MAP: Record<string, string> = {
|
||||
shell: "Shell",
|
||||
Bash: "Shell",
|
||||
read_file: "Read",
|
||||
apply_patch: "Edit",
|
||||
};
|
||||
|
||||
const ToolCallDisplaySchema = z
|
||||
.object({
|
||||
toolName: z.string(),
|
||||
input: z.unknown(),
|
||||
result: z.unknown(),
|
||||
})
|
||||
.transform((data) => {
|
||||
const normalizedToolName = TOOL_NAME_MAP[data.toolName] ?? data.toolName;
|
||||
|
||||
// Try each schema in order
|
||||
const shellParsed = ShellToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (shellParsed.success) {
|
||||
return { ...shellParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
const editParsed = EditToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (editParsed.success) {
|
||||
return { ...editParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
// Codex apply_patch - try before read since it also uses files array
|
||||
const applyPatchParsed = ApplyPatchToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (applyPatchParsed.success) {
|
||||
return { ...applyPatchParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
const readParsed = ReadToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (readParsed.success) {
|
||||
return { ...readParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
// Codex read_file
|
||||
const codexReadParsed = CodexReadToolCallSchema.safeParse({ input: data.input, result: data.result });
|
||||
if (codexReadParsed.success) {
|
||||
return { ...codexReadParsed.data, toolName: normalizedToolName };
|
||||
}
|
||||
|
||||
// Fallback to generic
|
||||
const genericParsed = GenericToolCallSchema.parse({ input: data.input, result: data.result });
|
||||
return { ...genericParsed, toolName: normalizedToolName };
|
||||
});
|
||||
|
||||
export type ToolCallDisplay = z.infer<typeof ToolCallDisplaySchema>;
|
||||
|
||||
export function parseToolCallDisplay(input: unknown, result: unknown): ToolCallDisplay {
|
||||
return ToolCallDisplaySchema.parse({ input, result });
|
||||
export function parseToolCallDisplay(toolName: string, input: unknown, result: unknown): ToolCallDisplay {
|
||||
return ToolCallDisplaySchema.parse({ toolName, input, result });
|
||||
}
|
||||
|
||||
// ---- Principal Parameter Extraction ----
|
||||
|
||||
Reference in New Issue
Block a user