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