From 5e1b24b217e814f8c5cabea90855b222f22285d8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 4 Apr 2026 15:37:16 +0700 Subject: [PATCH] refactor(app): remove launcher tabs from workspace flow --- .../src/components/adaptive-modal-sheet.tsx | 84 ++++-- .../app/src/components/agent-status-bar.tsx | 51 +--- .../components/combined-model-selector.tsx | 16 +- packages/app/src/components/composer.tsx | 4 + packages/app/src/components/message-input.tsx | 5 +- .../app/src/components/split-container.tsx | 24 +- .../src/components/workspace-setup-dialog.tsx | 154 ++++++----- .../contexts/session-stream-reducers.test.ts | 146 ++++++---- .../src/contexts/session-stream-reducers.ts | 52 ++-- .../app/src/hooks/use-agent-form-state.ts | 87 +----- .../app/src/hooks/use-open-project.test.ts | 24 +- packages/app/src/hooks/use-open-project.ts | 11 +- packages/app/src/hooks/use-provider-models.ts | 49 ++++ packages/app/src/panels/launcher-panel.tsx | 250 ------------------ packages/app/src/panels/register-panels.ts | 2 - .../screens/workspace/workspace-screen.tsx | 57 ++-- .../screens/workspace/workspace-tab-menu.ts | 3 - .../src/stores/workspace-layout-actions.ts | 20 +- .../src/stores/workspace-layout-store.test.ts | 89 +++---- .../app/src/stores/workspace-layout-store.ts | 22 -- .../src/stores/workspace-tabs-store.test.ts | 23 +- .../app/src/stores/workspace-tabs-store.ts | 10 - .../app/src/utils/workspace-tab-identity.ts | 18 -- scripts/dev.sh | 7 + 24 files changed, 473 insertions(+), 735 deletions(-) create mode 100644 packages/app/src/hooks/use-provider-models.ts delete mode 100644 packages/app/src/panels/launcher-panel.tsx diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index a9c282f4f..ccd9a12c5 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -13,6 +13,8 @@ import { type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import { X } from "lucide-react-native"; +import { FileDropZone } from "@/components/file-drop-zone"; +import type { ImageAttachment } from "@/components/message-input"; const styles = StyleSheet.create((theme) => ({ desktopOverlay: { @@ -32,16 +34,21 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.xl, borderWidth: 1, borderColor: theme.colors.surface2, - overflow: "hidden", }, header: { paddingHorizontal: theme.spacing[6], paddingVertical: theme.spacing[4], flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", justifyContent: "space-between", borderBottomWidth: 1, borderBottomColor: theme.colors.surface2, + gap: theme.spacing[3], + }, + headerTitleGroup: { + flex: 1, + gap: theme.spacing[2], + minWidth: 0, }, title: { color: theme.colors.foreground, @@ -69,9 +76,10 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[3], flexDirection: "row", justifyContent: "space-between", - alignItems: "center", + alignItems: "flex-start", borderBottomWidth: 1, borderBottomColor: theme.colors.surface2, + gap: theme.spacing[3], }, bottomSheetContent: { padding: theme.spacing[6], @@ -97,22 +105,31 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { export interface AdaptiveModalSheetProps { title: string; + /** Optional content rendered below the title in the header area. */ + subtitle?: ReactNode; visible: boolean; onClose: () => void; children: ReactNode; snapPoints?: string[]; stackBehavior?: "push" | "switch" | "replace"; testID?: string; + /** Override the max width of the desktop card. */ + desktopMaxWidth?: number; + /** When provided, wraps the card content in a FileDropZone. */ + onFilesDropped?: (files: ImageAttachment[]) => void; } export function AdaptiveModalSheet({ title, + subtitle, visible, onClose, children, snapPoints, stackBehavior, testID, + desktopMaxWidth, + onFilesDropped, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -120,6 +137,19 @@ export function AdaptiveModalSheet({ const dismissingForVisibilityRef = useRef(false); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); + useEffect(() => { + if (isMobile || !visible || Platform.OS !== "web" || typeof window === "undefined") return; + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + } + // Capture phase: RN Web TextInput stops propagation, so bubbling listeners never fire. + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [isMobile, visible, onClose]); + useEffect(() => { if (!isMobile) return; if (visible) { @@ -168,7 +198,10 @@ export function AdaptiveModalSheet({ keyboardBlurBehavior="restore" > - {title} + + {title} + {subtitle} + @@ -184,6 +217,28 @@ export function AdaptiveModalSheet({ ); } + const cardInner = ( + <> + + + {title} + {subtitle} + + + + + + + {children} + + + ); + const desktopContent = ( - - - {title} - - - - - - {children} - + + {onFilesDropped ? ( + + {cardInner} + + ) : cardInner} ); diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index afbd7d866..177d625e7 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -6,7 +6,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional"; import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; -import { useQuery } from "@tanstack/react-query"; +import { useProviderModels } from "@/hooks/use-provider-models"; import { useSessionStore } from "@/stores/session-store"; import { buildFavoriteModelKey, @@ -39,7 +39,7 @@ import { getStatusSelectorHint, resolveAgentModelSelection, } from "@/components/agent-status-bar.utils"; -import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading"; + type StatusOption = { id: string; @@ -626,52 +626,15 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const modelsQuery = useQuery({ - queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"], - enabled: Boolean(client && agent?.provider), - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client || !agent) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }); - if (payload.error) { - throw new Error(payload.error); - } - return payload.models ?? []; - }, - }); + const { allProviderModels: providerModelsMap, isLoading: isProviderModelsLoading } = + useProviderModels(serverId); const agentProviderDefinitions = useMemo(() => { const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); return definition ? [definition] : []; }, [agent?.provider]); - const agentProviderModelQuery = useQuery({ - queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""], - enabled: Boolean(client && agent?.cwd && agent?.provider), - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client || !agent) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }); - if (payload.error) { - throw new Error(payload.error); - } - return payload.models ?? []; - }, - }); - - const agentProviderModels = useMemo(() => { - const map = new Map(); - if (agent?.provider && agentProviderModelQuery.data) { - map.set(agent.provider, agentProviderModelQuery.data); - } - return map; - }, [agent?.provider, agentProviderModelQuery.data]); - - const models = modelsQuery.data ?? null; + const models = agent?.provider ? (providerModelsMap.get(agent.provider) ?? null) : null; const displayMode = availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || @@ -719,7 +682,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { } selectedModeId={agent.currentModeId ?? undefined} providerDefinitions={agentProviderDefinitions} - allProviderModels={agentProviderModels} + allProviderModels={providerModelsMap} onSelectMode={(modeId) => { if (!client) { return; @@ -782,7 +745,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); }); }} - isModelLoading={isProviderModelsQueryLoading(modelsQuery)} + isModelLoading={isProviderModelsLoading} disabled={!client} /> ); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 1d62aa555..6340b51e8 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -68,6 +68,7 @@ interface SelectorContentProps { onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; onBack?: () => void; + isLoading?: boolean; } function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { @@ -338,6 +339,7 @@ function SelectorContent({ onToggleFavorite, onDrillDown, onBack, + isLoading, }: SelectorContentProps) { const allRows = useMemo( () => buildModelRows(providerDefinitions, allProviderModels), @@ -404,8 +406,17 @@ function SelectorContent({ {favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( - - No models match your search + {isLoading ? ( + <> + + Loading models… + + ) : ( + <> + + No models match your search + + )} ) : null} @@ -583,6 +594,7 @@ export function CombinedModelSelector({ onSelect={handleSelect} canSelectProvider={canSelectProvider} onToggleFavorite={onToggleFavorite} + isLoading={isLoading} onDrillDown={(providerId, providerLabel) => { setView({ kind: "provider", providerId, providerLabel }); }} diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index f8a703c9f..01ac9659a 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -84,6 +84,8 @@ interface ComposerProps { onAttentionPromptSend?: () => void; /** Controlled status controls rendered in input area (draft flows). */ statusControls?: DraftAgentStatusBarProps; + /** Extra styles merged onto the message input wrapper (e.g. elevated background). */ + inputWrapperStyle?: import("react-native").ViewStyle; } const EMPTY_ARRAY: readonly QueuedMessage[] = []; @@ -111,6 +113,7 @@ export function Composer({ onAttentionInputFocus, onAttentionPromptSend, statusControls, + inputWrapperStyle, }: ComposerProps) { markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); const { theme } = useUnistyles(); @@ -711,6 +714,7 @@ export function Composer({ } }} onHeightChange={onComposerHeightChange} + inputWrapperStyle={inputWrapperStyle} /> diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 8a2127589..b672b6a3f 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -96,6 +96,8 @@ export interface MessageInputProps { onSelectionChange?: (selection: { start: number; end: number }) => void; onFocusChange?: (focused: boolean) => void; onHeightChange?: (height: number) => void; + /** Extra styles merged onto the input wrapper (e.g. elevated background). */ + inputWrapperStyle?: import("react-native").ViewStyle; } export interface MessageInputRef { @@ -213,6 +215,7 @@ export const MessageInput = forwardRef(funct onSelectionChange: onSelectionChangeCallback, onFocusChange, onHeightChange, + inputWrapperStyle, }, ref, ) { @@ -902,7 +905,7 @@ export const MessageInput = forwardRef(funct return ( {/* Regular input */} - + {/* Image preview pills */} {hasImages && ( diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index f131167cf..c12e023e3 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -87,7 +87,8 @@ interface SplitContainerProps { onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; - onCreateLauncherTab: (input: { paneId?: string }) => void; + onCreateDraftTab: (input: { paneId?: string }) => void; + onCreateTerminalTab: (input: { paneId?: string }) => void; buildPaneContentModel: (input: { paneId: string; isPaneFocused: boolean; @@ -263,7 +264,8 @@ export function SplitContainer({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onCreateLauncherTab, + onCreateDraftTab, + onCreateTerminalTab, buildPaneContentModel, onFocusPane, onSplitPane, @@ -530,7 +532,8 @@ export function SplitContainer({ onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} - onCreateLauncherTab={onCreateLauncherTab} + onCreateDraftTab={onCreateDraftTab} + onCreateTerminalTab={onCreateTerminalTab} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} @@ -650,7 +653,8 @@ function SplitNodeView({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onCreateLauncherTab, + onCreateDraftTab, + onCreateTerminalTab, buildPaneContentModel, onFocusPane, onSplitPane, @@ -683,7 +687,8 @@ function SplitNodeView({ onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} - onCreateLauncherTab={onCreateLauncherTab} + onCreateDraftTab={onCreateDraftTab} + onCreateTerminalTab={onCreateTerminalTab} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} @@ -731,7 +736,8 @@ function SplitNodeView({ onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} - onCreateLauncherTab={onCreateLauncherTab} + onCreateDraftTab={onCreateDraftTab} + onCreateTerminalTab={onCreateTerminalTab} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} @@ -778,7 +784,8 @@ function SplitPaneView({ onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, - onCreateLauncherTab, + onCreateDraftTab, + onCreateTerminalTab, buildPaneContentModel, onFocusPane, onSplitPane, @@ -887,7 +894,8 @@ function SplitPaneView({ onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)} onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} - onCreateLauncherTab={onCreateLauncherTab} + onCreateDraftTab={onCreateDraftTab} + onCreateTerminalTab={onCreateTerminalTab} onReorderTabs={(nextTabs) => { onReorderTabsInPane( pane.id, diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 3656df4fb..6804a51b7 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,23 +1,32 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Image, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Composer } from "@/components/composer"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; +import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { requireWorkspaceExecutionAuthority, requireWorkspaceRecordId, } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; -import type { MessagePayload } from "./message-input"; +import type { ImageAttachment, MessagePayload } from "./message-input"; + +function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { + if (!icon) { + return null; + } + return `data:${icon.mimeType};base64,${icon.data}`; +} export function WorkspaceSetupDialog() { const { theme } = useUnistyles(); @@ -31,7 +40,7 @@ export function WorkspaceSetupDialog() { const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); - const [pendingAction, setPendingAction] = useState<"chat" | "terminal" | null>(null); + const [pendingAction, setPendingAction] = useState<"chat" | null>(null); const serverId = pendingWorkspaceSetup?.serverId ?? ""; const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; @@ -48,7 +57,7 @@ export function WorkspaceSetupDialog() { : undefined, isVisible: pendingWorkspaceSetup !== null, onlineServerIds: isConnected && serverId ? [serverId] : [], - lockedWorkingDir: workspace?.workspaceDirectory || undefined, + lockedWorkingDir: workspace?.workspaceDirectory || sourceDirectory || undefined, }, }); const composerState = chatDraft.composerState; @@ -56,6 +65,12 @@ export function WorkspaceSetupDialog() { throw new Error("Workspace setup composer state is required"); } + const { icon: projectIcon } = useProjectIconQuery({ + serverId, + cwd: sourceDirectory, + }); + const iconDataUri = toProjectIconDataUri(projectIcon); + useEffect(() => { setErrorMessage(null); setCreatedWorkspace(null); @@ -208,34 +223,6 @@ export function WorkspaceSetupDialog() { ], ); - const handleCreateTerminal = useCallback(async () => { - try { - setPendingAction("terminal"); - setErrorMessage(null); - const workspace = await ensureWorkspace(); - const connectedClient = withConnectedClient(); - const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; - - const payload = await connectedClient.createTerminal(workspaceDirectory); - if (payload.error || !payload.terminal) { - throw new Error(payload.error ?? "Failed to open terminal"); - } - - if (!getIsStillActive()) { - return; - } - - navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id }); - } catch (error) { - const message = toErrorMessage(error); - setErrorMessage(message); - toast.error(message); - } finally { - if (getIsStillActive()) { - setPendingAction(null); - } - } - }, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]); const workspaceTitle = workspace?.name || @@ -243,25 +230,53 @@ export function WorkspaceSetupDialog() { displayName || sourceDirectory.split(/[\\/]/).filter(Boolean).pop() || sourceDirectory; - const workspacePath = workspace?.workspaceDirectory || "Workspace will be created before launch."; + + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); + + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, []); + + const composerInputWrapperStyle = useMemo( + () => ({ backgroundColor: theme.colors.surface2 }), + [theme.colors.surface2], + ); if (!pendingWorkspaceSetup || !sourceDirectory) { return null; } + const subtitleContent = ( + + {iconDataUri ? ( + + ) : ( + + {placeholderInitial} + + )} + + {workspaceTitle} + + + ); + return ( - - {workspaceTitle} - {workspacePath} - - - void handleCreateTerminal()} - style={[ - styles.terminalLink, - pendingAction !== null && pendingAction !== "terminal" ? { opacity: 0.5 } : undefined, - ]} - > - {pendingAction === "terminal" ? ( - - ) : null} - {"\u2192"} Open terminal - - {errorMessage ? {errorMessage} : null} ); } const styles = StyleSheet.create((theme) => ({ - header: { - gap: theme.spacing[1], + subtitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], }, - workspaceTitle: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, + projectIcon: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, }, - workspacePath: { - fontSize: theme.fontSize.xs, + projectIconFallback: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + projectIconFallbackText: { + color: theme.colors.foregroundMuted, + fontSize: 9, + }, + projectTitle: { + fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, section: { gap: theme.spacing[3], marginHorizontal: -theme.spacing[6], - }, - terminalLink: { - flexDirection: "row", - alignItems: "center", - alignSelf: "flex-end", - gap: theme.spacing[2], - }, - terminalLinkText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, + marginVertical: -theme.spacing[2], }, errorText: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/contexts/session-stream-reducers.test.ts b/packages/app/src/contexts/session-stream-reducers.test.ts index 138baf8a8..45f7a06ed 100644 --- a/packages/app/src/contexts/session-stream-reducers.test.ts +++ b/packages/app/src/contexts/session-stream-reducers.test.ts @@ -279,52 +279,52 @@ describe("processAgentStreamEvent", () => { expect(result.cursorChanged).toBe(false); }); - it("appends committed live rows to tail and clears superseded provisional assistant state", () => { - const currentHead: StreamItem[] = [ - { - kind: "assistant_message", - id: "head-assistant", - text: "partial", - timestamp: new Date(1000), - }, - ]; - const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("finalized reply"), - seq: 121, - currentHead, - currentCursor, - }); - - expect(result.changedTail).toBe(true); - expect(result.changedHead).toBe(true); - expect(result.head).toEqual([]); - expect(result.cursorChanged).toBe(true); - expect(result.cursor).toEqual({ - startSeq: 1, - endSeq: 121, - }); - expect(result.tail[result.tail.length - 1]).toMatchObject({ - kind: "assistant_message", - text: "finalized reply", - }); - }); - - it("replaces provisional tool progress when the committed tool row arrives", () => { - const provisional = processAgentStreamEvent({ + it("keeps tool call rows anchored in tail across live and committed updates", () => { + const running = processAgentStreamEvent({ ...baseStreamInput, event: makeToolCallEvent("running"), seq: undefined, }); + expect(running.head).toEqual([]); + expect(running.tail).toHaveLength(1); + expect(running.tail[0]).toMatchObject({ + kind: "tool_call", + payload: { + source: "agent", + data: { + callId: "call-1", + status: "running", + }, + }, + }); + + const completedLive = processAgentStreamEvent({ + ...baseStreamInput, + event: makeToolCallEvent("completed"), + seq: undefined, + currentHead: running.head, + currentTail: running.tail, + currentCursor: { startSeq: 1, endSeq: 7 }, + }); + expect(completedLive.head).toEqual([]); + expect(completedLive.tail).toHaveLength(1); + expect(completedLive.tail[0]).toMatchObject({ + kind: "tool_call", + payload: { + source: "agent", + data: { + callId: "call-1", + status: "completed", + }, + }, + }); const committed = processAgentStreamEvent({ ...baseStreamInput, event: makeToolCallEvent("completed"), seq: 8, - currentHead: provisional.head, - currentTail: provisional.tail, + currentHead: completedLive.head, + currentTail: completedLive.tail, currentCursor: { startSeq: 1, endSeq: 7 }, }); @@ -342,6 +342,51 @@ describe("processAgentStreamEvent", () => { }); }); + it("preserves assistant/tool interleaving while a turn is streaming", () => { + const assistantBeforeTool = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("before"), + seq: undefined, + }); + + const runningTool = processAgentStreamEvent({ + ...baseStreamInput, + event: makeToolCallEvent("running"), + seq: undefined, + currentHead: assistantBeforeTool.head, + currentTail: assistantBeforeTool.tail, + }); + + const assistantAfterTool = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("after"), + seq: undefined, + currentHead: runningTool.head, + currentTail: runningTool.tail, + }); + + const completedTool = processAgentStreamEvent({ + ...baseStreamInput, + event: makeToolCallEvent("completed"), + seq: undefined, + currentHead: assistantAfterTool.head, + currentTail: assistantAfterTool.tail, + }); + + expect(completedTool.head).toEqual([]); + expect(completedTool.tail.map((item) => item.kind)).toEqual([ + "assistant_message", + "tool_call", + "assistant_message", + ]); + expect( + completedTool.tail[0]?.kind === "assistant_message" ? completedTool.tail[0].text : null, + ).toBe("before"); + expect( + completedTool.tail[2]?.kind === "assistant_message" ? completedTool.tail[2].text : null, + ).toBe("after"); + }); + it("requests catch-up when a committed live row skips ahead", () => { const result = processAgentStreamEvent({ ...baseStreamInput, @@ -359,27 +404,30 @@ describe("processAgentStreamEvent", () => { }); }); - it("clears provisional head on terminal turn events without committing it to tail", () => { + it("flushes provisional head into tail on terminal turn events", () => { + const withHead = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("streaming"), + seq: undefined, + }); + const result = processAgentStreamEvent({ ...baseStreamInput, event: { type: "turn_completed", provider: "claude", }, - currentHead: [ - { - kind: "thought", - id: "reasoning-1", - text: "thinking", - timestamp: new Date(1000), - status: "loading", - }, - ], + currentHead: withHead.head, + currentTail: withHead.tail, }); expect(result.changedHead).toBe(true); - expect(result.changedTail).toBe(false); + expect(result.changedTail).toBe(true); expect(result.head).toEqual([]); - expect(result.tail).toEqual([]); + expect(result.tail).toHaveLength(1); + expect(result.tail[0]).toMatchObject({ + kind: "assistant_message", + text: "streaming", + }); }); }); diff --git a/packages/app/src/contexts/session-stream-reducers.ts b/packages/app/src/contexts/session-stream-reducers.ts index 5600d57ac..1c2ac4a4d 100644 --- a/packages/app/src/contexts/session-stream-reducers.ts +++ b/packages/app/src/contexts/session-stream-reducers.ts @@ -1,7 +1,7 @@ import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { StreamItem } from "@/types/stream"; -import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; +import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; import { classifySessionTimelineSeq, type SessionTimelineSeqDecision, @@ -289,10 +289,7 @@ export function processAgentStreamEvent( ): ProcessAgentStreamEventOutput { const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input; - let nextTail = currentTail; - let nextHead = currentHead; - let changedTail = false; - let changedHead = false; + let shouldApplyStreamEvent = true; let nextTimelineCursor: TimelineCursor | null = null; let cursorChanged = false; const sideEffects: AgentStreamReducerSideEffect[] = []; @@ -304,42 +301,39 @@ export function processAgentStreamEvent( }); if (decision === "gap") { + shouldApplyStreamEvent = false; if (currentCursor) { sideEffects.push({ type: "catch_up", cursor: { endSeq: currentCursor.endSeq }, }); } - } else if (decision !== "drop_stale") { - nextTail = reduceStreamUpdate(currentTail, event, timestamp, { - source: "canonical", - }); - changedTail = nextTail !== currentTail; - - nextHead = removeSupersededProvisionalItems(currentHead, event); - changedHead = nextHead !== currentHead; - + } else if (decision === "drop_stale") { + shouldApplyStreamEvent = false; + } else { nextTimelineCursor = decision === "init" ? { startSeq: seq, endSeq: seq } : { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq }; cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor); } - } else if (event.type === "timeline") { - nextHead = reduceStreamUpdate(currentHead, event, timestamp, { - source: "live", - }); - changedHead = nextHead !== currentHead; - } else if ( - (event.type === "turn_completed" || - event.type === "turn_canceled" || - event.type === "turn_failed") && - currentHead.length > 0 - ) { - nextHead = []; - changedHead = true; } + const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent + ? applyStreamEvent({ + tail: currentTail, + head: currentHead, + event, + timestamp, + source: "live", + }) + : { + tail: currentTail, + head: currentHead, + changedTail: false, + changedHead: false, + }; + let agentPatch: AgentPatch | null = null; let agentChanged = false; @@ -366,8 +360,8 @@ export function processAgentStreamEvent( } return { - tail: nextTail, - head: nextHead, + tail, + head, changedTail, changedHead, cursor: nextTimelineCursor, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 25a78f49d..b72daa6cc 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery, useQueries } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -11,6 +11,7 @@ import type { } from "@server/server/agent/agent-sdk-types"; import { useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useProviderModels } from "@/hooks/use-provider-models"; import { useFormPreferences, mergeProviderPreferences, @@ -409,75 +410,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [providerDefinitions], ); - const [debouncedCwd, setDebouncedCwd] = useState(undefined); - useEffect(() => { - const trimmed = formState.workingDir.trim(); - const next = trimmed.length > 0 ? trimmed : undefined; - const timer = setTimeout(() => setDebouncedCwd(next), 180); - return () => clearTimeout(timer); - }, [formState.workingDir]); + const { allProviderModels, isLoading: isAllModelsLoading } = useProviderModels( + formState.serverId ?? "", + ); - const providerModelsQuery = useQuery({ - queryKey: ["providerModels", formState.serverId, formState.provider], - enabled: Boolean( - isVisible && - isTargetDaemonReady && - formState.serverId && - client && - isConnected && - providerDefinitionMap.has(formState.provider), - ), - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - const payload = await client.listProviderModels(formState.provider, { - cwd: debouncedCwd, - }); - if (payload.error) { - throw new Error(payload.error); - } - return payload.models ?? []; - }, - }); - - const availableModels = providerModelsQuery.data ?? null; - - const allProviderModelQueries = useQueries({ - queries: providerDefinitions.map((def) => ({ - queryKey: ["providerModels", formState.serverId, def.id], - enabled: Boolean( - isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, - ), - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - const payload = await client.listProviderModels(def.id as AgentProvider, { - cwd: debouncedCwd, - }); - if (payload.error) { - throw new Error(payload.error); - } - return payload.models ?? []; - }, - })), - }); - - const allProviderModels = useMemo(() => { - const map = new Map(); - for (let i = 0; i < providerDefinitions.length; i++) { - const query = allProviderModelQueries[i]; - if (query?.data) { - map.set(providerDefinitions[i]!.id, query.data); - } - } - return map; - }, [allProviderModelQueries, providerDefinitions]); - - const isAllModelsLoading = allProviderModelQueries.some((q) => q.isLoading); + const availableModels = allProviderModels.get(formState.provider) ?? null; // Combine initialValues with initialServerId for resolution const combinedInitialValues = useMemo((): FormInitialValues | undefined => { @@ -687,9 +624,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg setFormState((prev) => ({ ...prev, serverId: value })); }, []); + const queryClient = useQueryClient(); const refreshProviderModels = useCallback(() => { - void providerModelsQuery.refetch(); - }, [providerModelsQuery]); + void queryClient.invalidateQueries({ + queryKey: ["providerModels", formState.serverId, formState.provider], + }); + }, [queryClient, formState.serverId, formState.provider]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -726,9 +666,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; - const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; - const modelError = - providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null; + const isModelLoading = !availableModels && isAllModelsLoading; + const modelError: string | null = null; const workingDirIsEmpty = !formState.workingDir.trim(); diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index e28d6579e..285dd4a73 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -32,10 +32,19 @@ import { collectAllTabs, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; +import { generateDraftId } from "@/stores/draft-keys"; const SERVER_ID = "server-1"; const WORKSPACE_ID = "/repo/project"; +function createOpenDraftTab() { + return (workspaceKey: string) => + useWorkspaceLayoutStore.getState().openTab(workspaceKey, { + kind: "draft", + draftId: generateDraftId(), + }); +} + describe("openProjectDirectly", () => { beforeEach(() => { replaceRoute.mockReset(); @@ -51,11 +60,7 @@ describe("openProjectDirectly", () => { vi.restoreAllMocks(); }); - it("opens the workspace directly, marks workspaces hydrated, and seeds a launcher tab", async () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "11111111-1111-1111-1111-111111111111", - ); - + it("opens the workspace directly, marks workspaces hydrated, and seeds a draft tab", async () => { const result = await openProjectDirectly({ serverId: SERVER_ID, projectPath: WORKSPACE_ID, @@ -82,7 +87,7 @@ describe("openProjectDirectly", () => { }, mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, - openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, + openDraftTab: createOpenDraftTab(), replaceRoute, }); @@ -106,10 +111,7 @@ describe("openProjectDirectly", () => { expect(layout.root.kind).toBe("pane"); const tabs = collectAllTabs(layout.root); expect(tabs).toHaveLength(1); - expect(tabs[0]?.target).toEqual({ - kind: "launcher", - launcherId: "11111111-1111-1111-1111-111111111111", - }); + expect(tabs[0]?.target.kind).toBe("draft"); expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ"); }); @@ -127,7 +129,7 @@ describe("openProjectDirectly", () => { }, mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, - openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, + openDraftTab: createOpenDraftTab(), replaceRoute, }); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index a90ed420e..e3ea6b238 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -7,6 +7,7 @@ import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; +import { generateDraftId } from "@/stores/draft-keys"; import { buildHostWorkspaceRoute } from "@/utils/host-routes"; interface OpenProjectDirectlyInput { @@ -16,7 +17,7 @@ interface OpenProjectDirectlyInput { client: Pick | null; mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; - openLauncherTab: (workspaceKey: string) => string | null; + openDraftTab: (workspaceKey: string) => string | null; replaceRoute: (route: string) => void; } @@ -44,7 +45,7 @@ export async function openProjectDirectly(input: OpenProjectDirectlyInput): Prom return false; } - input.openLauncherTab(workspaceKey); + input.openDraftTab(workspaceKey); input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id)); return true; } @@ -65,7 +66,11 @@ export function useOpenProject(serverId: string | null): (path: string) => Promi client, mergeWorkspaces, setHasHydratedWorkspaces, - openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, + openDraftTab: (workspaceKey: string) => + useWorkspaceLayoutStore.getState().openTab(workspaceKey, { + kind: "draft", + draftId: generateDraftId(), + }), replaceRoute: (route) => { router.replace(route as any); }, diff --git a/packages/app/src/hooks/use-provider-models.ts b/packages/app/src/hooks/use-provider-models.ts new file mode 100644 index 000000000..25e861e79 --- /dev/null +++ b/packages/app/src/hooks/use-provider-models.ts @@ -0,0 +1,49 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { + AGENT_PROVIDER_DEFINITIONS, + type AgentProviderDefinition, +} from "@server/server/agent/provider-manifest"; +import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +const STALE_TIME = 5 * 60 * 1000; + +export function useProviderModels(serverId: string) { + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const enabled = Boolean(serverId && client && isConnected); + + const queries = useQueries({ + queries: AGENT_PROVIDER_DEFINITIONS.map((def) => ({ + queryKey: ["providerModels", serverId, def.id] as const, + enabled, + staleTime: STALE_TIME, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderModels(def.id as AgentProvider); + if (payload.error) { + throw new Error(payload.error); + } + return payload.models ?? []; + }, + })), + }); + + const allProviderModels = useMemo(() => { + const map = new Map(); + for (let i = 0; i < AGENT_PROVIDER_DEFINITIONS.length; i++) { + const query = queries[i]; + if (query?.data) { + map.set(AGENT_PROVIDER_DEFINITIONS[i]!.id, query.data); + } + } + return map; + }, [queries]); + + const isLoading = queries.some((q) => q.isLoading); + + return { allProviderModels, isLoading }; +} diff --git a/packages/app/src/panels/launcher-panel.tsx b/packages/app/src/panels/launcher-panel.tsx deleted file mode 100644 index e410bd273..000000000 --- a/packages/app/src/panels/launcher-panel.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { useCallback, useState, type ComponentType } from "react"; -import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; -import { Plus, SquarePen, SquareTerminal } from "lucide-react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import invariant from "tiny-invariant"; -import { usePaneContext } from "@/panels/pane-context"; -import type { PanelRegistration } from "@/panels/panel-registry"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { generateDraftId } from "@/stores/draft-keys"; -import { useSessionStore } from "@/stores/session-store"; -import { toErrorMessage } from "@/utils/error-messages"; -import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; - -function useLauncherPanelDescriptor() { - return { - label: "New Tab", - subtitle: "New Tab", - titleState: "ready" as const, - icon: Plus, - statusBucket: null, - }; -} - -function LauncherPanel() { - const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext(); - const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); - const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); - const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); - const workspaceDirectory = workspaceAuthority.ok - ? workspaceAuthority.authority.workspaceDirectory - : null; - const [pendingAction, setPendingAction] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - invariant(target.kind === "launcher", "LauncherPanel requires launcher target"); - - const openDraftTab = useCallback(() => { - setErrorMessage(null); - setPendingAction("draft"); - retargetCurrentTab({ - kind: "draft", - draftId: generateDraftId(), - }); - setPendingAction(null); - }, [retargetCurrentTab]); - - const openTerminalTab = useCallback(async () => { - if (!client || !isConnected || !workspaceDirectory) { - setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected"); - return; - } - - setPendingAction("terminal"); - setErrorMessage(null); - - try { - const payload = await client.createTerminal(workspaceDirectory); - if (payload.error || !payload.terminal) { - throw new Error(payload.error ?? "Failed to open terminal"); - } - retargetCurrentTab({ - kind: "terminal", - terminalId: payload.terminal.id, - }); - } catch (error) { - setErrorMessage(toErrorMessage(error)); - } finally { - setPendingAction((current) => (current === "terminal" ? null : current)); - } - }, [client, isConnected, retargetCurrentTab, workspaceDirectory]); - - const actionsDisabled = pendingAction !== null; - - if (!workspaceDirectory) { - return ( - - - - {workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message} - - - - ); - } - - return ( - - - - - - { - void openTerminalTab(); - }} - /> - - {errorMessage ? {errorMessage} : null} - - - - ); -} - -function LauncherTile({ - title, - Icon, - accent = false, - disabled, - pending, - onPress, -}: { - title: string; - Icon: ComponentType<{ size: number; color: string }>; - accent?: boolean; - disabled: boolean; - pending: boolean; - onPress: () => void; -}) { - const { theme } = useUnistyles(); - const iconColor = accent ? theme.colors.accentForeground : theme.colors.foreground; - const titleColor = accent ? theme.colors.accentForeground : theme.colors.foreground; - - return ( - [ - styles.primaryTile, - accent ? styles.primaryTileAccent : null, - (hovered || pressed) && !disabled - ? accent - ? styles.primaryTileAccentInteractive - : styles.tileInteractive - : null, - disabled ? styles.tileDisabled : null, - ]} - > - - {pending ? ( - - ) : ( - - )} - - {title} - - ); -} - -export const launcherPanelRegistration: PanelRegistration<"launcher"> = { - kind: "launcher", - component: LauncherPanel, - useDescriptor: useLauncherPanelDescriptor, -}; - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - content: { - flexGrow: 1, - justifyContent: "center", - alignItems: "center", - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[8], - }, - loadingContent: { - flex: 1, - }, - contentUnfocused: { - opacity: 0.96, - }, - inner: { - width: "100%", - maxWidth: 360, - gap: theme.spacing[4], - }, - primaryRow: { - flexDirection: "row", - gap: theme.spacing[2], - }, - tileInteractive: { - backgroundColor: theme.colors.surface2, - }, - tileDisabled: { - opacity: theme.opacity[50], - }, - primaryTile: { - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.borderAccent, - backgroundColor: theme.colors.surface1, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - }, - primaryTileAccent: { - backgroundColor: theme.colors.accent, - borderColor: theme.colors.accent, - }, - primaryTileAccentInteractive: { - backgroundColor: theme.colors.accentBright, - borderColor: theme.colors.accentBright, - }, - primaryIconWrap: { - width: 28, - height: 28, - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", - backgroundColor: theme.colors.surface2, - }, - primaryIconWrapAccent: { - backgroundColor: "rgba(255,255,255,0.14)", - }, - primaryTileTitle: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - errorText: { - fontSize: theme.fontSize.sm, - color: theme.colors.destructive, - }, -})); diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 3afb57025..dfc14f7fe 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -1,7 +1,6 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; -import { launcherPanelRegistration } from "@/panels/launcher-panel"; import { registerPanel } from "@/panels/panel-registry"; import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; @@ -17,6 +16,5 @@ export function ensurePanelsRegistered(): void { registerPanel(setupPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(filePanelRegistration); - registerPanel(launcherPanelRegistration); panelsRegistered = true; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 7aeb092d9..42b948a15 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -67,6 +67,7 @@ import { workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useProviderModels } from "@/hooks/use-provider-models"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { checkoutStatusQueryKey, @@ -143,9 +144,6 @@ function decodeSegment(value: string): string { } function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { - if (tab.target.kind === "launcher") { - return "New Tab"; - } if (tab.target.kind === "draft") { return "New Agent"; } @@ -162,9 +160,6 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { } function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { - if (tab.target.kind === "launcher") { - return "New Tab"; - } if (tab.target.kind === "draft") { return "New Agent"; } @@ -593,6 +588,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; + + // Prefetch provider models early so the model picker is warm by the time it opens + useProviderModels(normalizedServerId); + const normalizedWorkspaceId = resolveWorkspaceRouteId({ routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId), @@ -853,7 +852,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [workspaceLayout], ); const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab); - const openWorkspaceLauncherTab = useWorkspaceLayoutStore((state) => state.openLauncherTab); const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); @@ -1107,27 +1105,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [tabs], ); - const handleCreateDraftTab = useCallback(() => { - openWorkspaceDraftTab(); - }, [openWorkspaceDraftTab]); - - const handleCreateLauncherTab = useCallback( + const handleCreateDraftTab = useCallback( (input?: { paneId?: string }) => { - if (!persistenceKey) { - return null; - } - - if (input?.paneId) { + if (input?.paneId && persistenceKey) { focusWorkspacePane(persistenceKey, input.paneId); } - - const tabId = openWorkspaceLauncherTab(persistenceKey); - if (tabId) { - focusWorkspaceTab(persistenceKey, tabId); - } - return tabId; + openWorkspaceDraftTab(); }, - [focusWorkspacePane, focusWorkspaceTab, openWorkspaceLauncherTab, persistenceKey], + [focusWorkspacePane, openWorkspaceDraftTab, persistenceKey], ); const handleCreateTerminal = useCallback( @@ -1150,7 +1135,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [navigateToTabId], ); - const handleCreateLauncherSplit = useCallback( + const handleCreateDraftSplit = useCallback( (input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => { if (!persistenceKey) { return; @@ -1161,9 +1146,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - handleCreateLauncherTab({ paneId }); + handleCreateDraftTab({ paneId }); }, - [handleCreateLauncherTab, persistenceKey, splitWorkspacePaneEmpty], + [handleCreateDraftTab, persistenceKey, splitWorkspacePaneEmpty], ); const killTerminalAsync = killTerminalMutation.mutateAsync; @@ -1493,7 +1478,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) (action: KeyboardActionDefinition): boolean => { switch (action.id) { case "workspace.tab.new": - handleCreateLauncherTab(); + handleCreateDraftTab(); return true; case "workspace.terminal.new": handleCreateTerminal(); @@ -1529,7 +1514,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) [ activeTabId, handleCloseTabById, - handleCreateLauncherTab, + handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs, @@ -1548,7 +1533,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } if (action.id === "workspace.pane.split.right") { - handleCreateLauncherSplit({ + handleCreateDraftSplit({ targetPaneId: focusedPane.id, position: "right", }); @@ -1556,7 +1541,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) } if (action.id === "workspace.pane.split.down") { - handleCreateLauncherSplit({ + handleCreateDraftSplit({ targetPaneId: focusedPane.id, position: "bottom", }); @@ -1626,7 +1611,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) allTabDescriptorsById, closeWorkspaceTabWithCleanup, focusWorkspacePane, - handleCreateLauncherSplit, + handleCreateDraftSplit, moveWorkspaceTabToPane, persistenceKey, focusedPaneTabState.activeTabId, @@ -2173,7 +2158,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} - onCreateLauncherTab={handleCreateLauncherTab} + onCreateDraftTab={handleCreateDraftTab} + onCreateTerminalTab={handleCreateTerminal} onReorderTabs={handleReorderTabsInFocusedPane} onSplitRight={() => {}} onSplitDown={() => {}} @@ -2207,11 +2193,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseOtherTabs={handleCloseOtherTabsInPane} - onCreateLauncherTab={handleCreateLauncherTab} + onCreateDraftTab={handleCreateDraftTab} + onCreateTerminalTab={handleCreateTerminal} buildPaneContentModel={buildDesktopPaneContentModel} onFocusPane={handleFocusPane} onSplitPane={handleSplitPane} - onSplitPaneEmpty={handleCreateLauncherSplit} + onSplitPaneEmpty={handleCreateDraftSplit} onMoveTabToPane={handleMoveTabToPane} onResizeSplit={handleResizePaneSplit} onReorderTabsInPane={handleReorderTabsInPane} diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index e1f3c5589..fc13a015a 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -78,9 +78,6 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return `workspace-draft-close-${tab.target.draftId}`; } - if (tab.target.kind === "launcher") { - return `workspace-launcher-close-${tab.target.launcherId}`; - } if (tab.target.kind === "setup") { return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`; } diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 8aa64b141..bcf170103 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -2,7 +2,6 @@ import invariant from "tiny-invariant"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { buildDeterministicWorkspaceTabId, - createLauncherId, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; @@ -111,10 +110,6 @@ interface OpenTabInLayoutResult { tabId: string; } -interface OpenLauncherTabInLayoutInput { - layout: WorkspaceLayout; - now: number; -} interface RetargetTabInLayoutInput { layout: WorkspaceLayout; @@ -1070,19 +1065,6 @@ export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutRes return insertNewTabIntoFocusedPane(input); } -export function openLauncherTabInLayout( - input: OpenLauncherTabInLayoutInput, -): OpenTabInLayoutResult { - return insertNewTabIntoFocusedPane({ - layout: input.layout, - target: { - kind: "launcher", - launcherId: createLauncherId(), - }, - now: input.now, - }); -} - export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null { const internalLayout = asInternalLayout(input.layout); const pane = findPaneContainingTab(internalLayout.root, input.tabId); @@ -1159,7 +1141,7 @@ export function retargetTabInLayout( } return { - // Preserve the existing tab id so launcher->entity transitions keep the same + // Preserve the existing tab id so draft->entity transitions keep the same // React key during the first render. Reconciliation can canonicalize later. tabId: input.tabId, layout: { diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 009aa8f9b..c83d317fc 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -262,36 +262,33 @@ describe("workspace-layout-store actions", () => { ]); }); - it("openLauncherTab creates duplicate launcher tabs for repeated Cmd+T/new-tab opens", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") - .mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); + it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => { const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); - const firstTabId = store.openLauncherTab(workspaceKey); - const secondTabId = store.openLauncherTab(workspaceKey); + const firstTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-1" }); + const secondTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-2" }); const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; - expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111"); - expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222"); + expect(firstTabId).toBe("draft-1"); + expect(secondTabId).toBe("draft-2"); expect(firstTabId).not.toBe(secondTabId); expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]); expect(collectAllTabs(layout.root)).toEqual([ { tabId: firstTabId, - target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" }, + target: { kind: "draft", draftId: "draft-1" }, createdAt: expect.any(Number), }, { tabId: secondTabId, - target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" }, + target: { kind: "draft", draftId: "draft-2" }, createdAt: expect.any(Number), }, ]); }); - it("splitPaneEmpty plus openLauncherTab opens a launcher tab in the new pane", () => { + it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => { vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce( "77777777-7777-7777-7777-777777777777", ); @@ -303,15 +300,15 @@ describe("workspace-layout-store actions", () => { targetPaneId: "main", position: "right", }); - const launcherTabId = store.openLauncherTab(workspaceKey); + const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-split" }); const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777"); - expect(launcherTabId).toMatch(/^launcher_/); + expect(draftTabId).toBe("draft-split"); expect(layout.focusedPaneId).toBe(newPaneId); expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]); - expect(findPaneById(layout.root, newPaneId!)?.tabIds).toEqual([launcherTabId!]); - expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(launcherTabId); + expect(findPaneById(layout.root, newPaneId!)?.tabIds).toEqual([draftTabId!]); + expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(draftTabId); }); it("focusTab moves workspace focus to the pane containing the tab", () => { @@ -371,37 +368,32 @@ describe("workspace-layout-store actions", () => { }); }); - it("retargetTab keeps a launcher tab in place while updating its target", () => { - vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue( - "33333333-3333-3333-3333-333333333333", - ); + it("retargetTab keeps a draft tab in place while updating its target", () => { const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); - const launcherTabId = store.openLauncherTab(workspaceKey); - const nextTabId = store.retargetTab(workspaceKey, launcherTabId!, { + const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-retarget" }); + const nextTabId = store.retargetTab(workspaceKey, draftTabId!, { kind: "file", - path: "/repo/worktree/launcher.ts", + path: "/repo/worktree/retargeted.ts", }); const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; - expect(launcherTabId).toBe("launcher_33333333-3333-3333-3333-333333333333"); - expect(nextTabId).toBe(launcherTabId); - expect(findPaneById(layout.root, "main")?.tabIds).toEqual([launcherTabId!]); + expect(draftTabId).toBe("draft-retarget"); + expect(nextTabId).toBe(draftTabId); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual([draftTabId!]); expect(collectAllTabs(layout.root)).toEqual([ { - tabId: launcherTabId!, - target: { kind: "file", path: "/repo/worktree/launcher.ts" }, + tabId: draftTabId!, + target: { kind: "file", path: "/repo/worktree/retargeted.ts" }, createdAt: expect.any(Number), }, ]); }); - it("retargetTab closes a launcher tab and focuses the existing canonical target tab", () => { + it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => { vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("44444444-4444-4444-4444-444444444444") - .mockReturnValueOnce("55555555-5555-5555-5555-555555555555") - .mockReturnValueOnce("66666666-6666-6666-6666-666666666666"); + .mockReturnValueOnce("55555555-5555-5555-5555-555555555555"); const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); @@ -409,64 +401,59 @@ describe("workspace-layout-store actions", () => { kind: "file", path: "/repo/worktree/existing.ts", }); - const launcherTabId = store.openLauncherTab(workspaceKey); + const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-dup" }); const splitPaneId = store.splitPane(workspaceKey, { - tabId: launcherTabId!, + tabId: draftTabId!, targetPaneId: "main", position: "right", }); - const secondLauncherTabId = store.openLauncherTab(workspaceKey); + const secondDraftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-dup-2" }); - const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, { + const nextTabId = store.retargetTab(workspaceKey, secondDraftTabId!, { kind: "file", path: "/repo/worktree/existing.ts", }); const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts"); - expect(launcherTabId).toBe("launcher_44444444-4444-4444-4444-444444444444"); + expect(draftTabId).toBe("draft-dup"); expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555"); - expect(secondLauncherTabId).toMatch(/^launcher_/); - expect(secondLauncherTabId).not.toBe(launcherTabId); expect(nextTabId).toBe(existingFileTabId); expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ existingFileTabId!, - launcherTabId!, + draftTabId!, ]); expect(layout.focusedPaneId).toBe("main"); expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId); }); - it("retargetTab closes a launcher tab and focuses an existing matching target tab", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("77777777-7777-7777-7777-777777777777") - .mockReturnValueOnce("88888888-8888-8888-8888-888888888888"); + it("retargetTab closes a draft tab and focuses an existing matching target tab", () => { const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); - const firstLauncherTabId = store.openLauncherTab(workspaceKey); - const firstAgentTabId = store.retargetTab(workspaceKey, firstLauncherTabId!, { + const firstDraftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-agent-1" }); + const firstAgentTabId = store.retargetTab(workspaceKey, firstDraftTabId!, { kind: "agent", agentId: "agent-1", }); - const secondLauncherTabId = store.openLauncherTab(workspaceKey); + const secondDraftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-agent-2" }); - const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, { + const nextTabId = store.retargetTab(workspaceKey, secondDraftTabId!, { kind: "agent", agentId: "agent-1", }); const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; - expect(firstAgentTabId).toBe(firstLauncherTabId); - expect(nextTabId).toBe(firstLauncherTabId); + expect(firstAgentTabId).toBe(firstDraftTabId); + expect(nextTabId).toBe(firstDraftTabId); expect(collectAllTabs(layout.root)).toEqual([ { - tabId: firstLauncherTabId!, + tabId: firstDraftTabId!, target: { kind: "agent", agentId: "agent-1" }, createdAt: expect.any(Number), }, ]); - expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstLauncherTabId); + expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstDraftTabId); }); it("reorderTabs reorders tabs within the focused pane", () => { diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index ef6af3889..116bef8fb 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -21,7 +21,6 @@ import { insertSplit, moveTabToPaneInLayout, normalizeLayout, - openLauncherTabInLayout, openTabInLayout, reconcileWorkspaceTabs, removePaneFromTree, @@ -67,7 +66,6 @@ interface WorkspaceLayoutStore { splitSizesByWorkspace: Record>; pinnedAgentIdsByWorkspace: Record>; openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; - openLauncherTab: (workspaceKey: string) => string | null; closeTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void; retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; @@ -143,26 +141,6 @@ export const useWorkspaceLayoutStore = create()( return result.tabId; }, - openLauncherTab: (workspaceKey) => { - const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); - if (!normalizedWorkspaceKey) { - return null; - } - - const result = openLauncherTabInLayout({ - layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), - now: Date.now(), - }); - - set((state) => ({ - layoutByWorkspace: { - ...state.layoutByWorkspace, - [normalizedWorkspaceKey]: result.layout, - }, - })); - - return result.tabId; - }, closeTab: (workspaceKey, tabId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTabId = trimNonEmpty(tabId); diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts index 079a523da..81a215484 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -140,36 +140,35 @@ describe("workspace-tabs-store retargetTab", () => { expect(order).toEqual([draftTabId]); }); - it("openLauncherTab creates distinct launcher tabs without deduplicating", () => { - vi.spyOn(globalThis.crypto, "randomUUID") - .mockReturnValueOnce("11111111-1111-1111-1111-111111111111") - .mockReturnValueOnce("22222222-2222-2222-2222-222222222222"); + it("openDraftTab creates a draft tab and deduplicates by draftId", () => { const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); expect(key).toBeTruthy(); const workspaceKey = key as string; - const firstTabId = useWorkspaceTabsStore.getState().openLauncherTab({ + const firstTabId = useWorkspaceTabsStore.getState().openDraftTab({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, + draftId: "draft-1", }); - const secondTabId = useWorkspaceTabsStore.getState().openLauncherTab({ + const secondTabId = useWorkspaceTabsStore.getState().openDraftTab({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, + draftId: "draft-2", }); const state = useWorkspaceTabsStore.getState(); - expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111"); - expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222"); + expect(firstTabId).toBe("draft-1"); + expect(secondTabId).toBe("draft-2"); expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]); expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([ { - tabId: "launcher_11111111-1111-1111-1111-111111111111", - target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" }, + tabId: "draft-1", + target: { kind: "draft", draftId: "draft-1" }, createdAt: expect.any(Number), }, { - tabId: "launcher_22222222-2222-2222-2222-222222222222", - target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" }, + tabId: "draft-2", + target: { kind: "draft", draftId: "draft-2" }, createdAt: expect.any(Number), }, ]); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 47b28d2e2..f1821ab32 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -3,7 +3,6 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { buildDeterministicWorkspaceTabId, - createLauncherId, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; @@ -13,7 +12,6 @@ export type WorkspaceTabTarget = | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "file"; path: string } - | { kind: "launcher"; launcherId: string } | { kind: "setup"; workspaceId: string }; export type WorkspaceTab = { @@ -84,7 +82,6 @@ type WorkspaceTabsState = { workspaceId: string; target: WorkspaceTabTarget; }) => string | null; - openLauncherTab: (input: { serverId: string; workspaceId: string }) => string | null; openOrFocusTab: (input: { serverId: string; workspaceId: string; @@ -171,13 +168,6 @@ export const useWorkspaceTabsStore = create()( return resolvedTabId; }, - openLauncherTab: ({ serverId, workspaceId }) => { - return get().openOrFocusTab({ - serverId, - workspaceId, - target: { kind: "launcher", launcherId: createLauncherId() }, - }); - }, openOrFocusTab: ({ serverId, workspaceId, target }) => { const tabId = get().ensureTab({ serverId, workspaceId, target }); if (!tabId) { diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 2341af17a..5de7dcff2 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -22,10 +22,6 @@ export function normalizeWorkspaceTabTarget( const path = trimNonEmpty(value.path); return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; } - if (value.kind === "launcher") { - const launcherId = trimNonEmpty(value.launcherId); - return launcherId ? { kind: "launcher", launcherId } : null; - } if (value.kind === "setup") { const workspaceId = trimNonEmpty(value.workspaceId); return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; @@ -52,10 +48,6 @@ export function workspaceTabTargetsEqual( if (left.kind === "file" && right.kind === "file") { return left.path === right.path; } - if (left.kind === "launcher" && right.kind === "launcher") { - // Launcher tabs are intentionally always unique, even when reopened repeatedly. - return false; - } if (left.kind === "setup" && right.kind === "setup") { return left.workspaceId === right.workspaceId; } @@ -72,22 +64,12 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } - if (target.kind === "launcher") { - return `launcher_${target.launcherId}`; - } if (target.kind === "setup") { return `setup_${target.workspaceId}`; } return `file_${target.path}`; } -export function createLauncherId(): string { - if (typeof globalThis.crypto?.randomUUID === "function") { - return globalThis.crypto.randomUUID(); - } - return `${Date.now()}-${Math.random().toString(16).slice(2)}`; -} - function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; diff --git a/scripts/dev.sh b/scripts/dev.sh index 8bcef241f..3ddc9104e 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -22,10 +22,17 @@ if [ -z "${PASEO_HOME}" ]; then fi fi +# Share speech models with the main install to avoid duplicate downloads +if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then + export PASEO_LOCAL_MODELS_DIR="$HOME/.paseo/models/local-speech" + mkdir -p "$PASEO_LOCAL_MODELS_DIR" +fi + echo "══════════════════════════════════════════════════════" echo " Paseo Dev" echo "══════════════════════════════════════════════════════" echo " Home: ${PASEO_HOME}" +echo " Models: ${PASEO_LOCAL_MODELS_DIR}" echo "══════════════════════════════════════════════════════" # Configure the daemon for the Portless app origin and let the app bootstrap