refactor(app): remove launcher tabs from workspace flow

This commit is contained in:
Mohamed Boudra
2026-04-04 15:37:26 +07:00
parent 2ca7dd71e2
commit 5e1b24b217
24 changed files with 473 additions and 735 deletions
@@ -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"
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title}>{title}</Text>
<View style={styles.headerTitleGroup}>
<Text style={styles.title}>{title}</Text>
{subtitle}
</View>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
@@ -184,6 +217,28 @@ export function AdaptiveModalSheet({
);
}
const cardInner = (
<>
<View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text style={styles.title}>{title}</Text>
{subtitle}
</View>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
);
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
@@ -191,21 +246,12 @@ export function AdaptiveModalSheet({
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>
{cardInner}
</FileDropZone>
) : cardInner}
</View>
</View>
);
@@ -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<string, AgentModelDefinition[]>();
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}
/>
);
@@ -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 ? (
<View style={styles.emptyState}>
<Search size={16} color="#777" />
<Text style={styles.emptyStateText}>No models match your search</Text>
{isLoading ? (
<>
<ActivityIndicator size="small" color="#777" />
<Text style={styles.emptyStateText}>Loading models…</Text>
</>
) : (
<>
<Search size={16} color="#777" />
<Text style={styles.emptyStateText}>No models match your search</Text>
</>
)}
</View>
) : null}
</View>
@@ -583,6 +594,7 @@ export function CombinedModelSelector({
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
isLoading={isLoading}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
+4
View File
@@ -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}
/>
</View>
</View>
@@ -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<MessageInputRef, MessageInputProps>(funct
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
onHeightChange,
inputWrapperStyle,
},
ref,
) {
@@ -902,7 +905,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
@@ -87,7 +87,8 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | 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,
@@ -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<ReturnType<
typeof normalizeWorkspaceDescriptor
> | 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 = (
<View style={styles.subtitleRow}>
{iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
<Text style={styles.projectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
);
return (
<AdaptiveModalSheet
title="Set up workspace"
title="Create workspace"
subtitle={subtitleContent}
visible={true}
onClose={handleClose}
snapPoints={["82%", "94%"]}
testID="workspace-setup-dialog"
desktopMaxWidth={640}
onFilesDropped={handleFilesDropped}
>
<View style={styles.header}>
<Text style={styles.workspaceTitle}>{workspaceTitle}</Text>
<Text style={styles.workspacePath}>{workspacePath}</Text>
</View>
<View style={styles.section}>
<Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
@@ -285,55 +300,48 @@ export function WorkspaceSetupDialog() {
}
: undefined
}
inputWrapperStyle={composerInputWrapperStyle}
onAddImages={handleAddImagesCallback}
/>
</View>
<Pressable
accessibilityRole="button"
disabled={pendingAction !== null}
onPress={() => void handleCreateTerminal()}
style={[
styles.terminalLink,
pendingAction !== null && pendingAction !== "terminal" ? { opacity: 0.5 } : undefined,
]}
>
{pendingAction === "terminal" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.terminalLinkText}>{"\u2192"} Open terminal</Text>
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
);
}
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,
@@ -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",
});
});
});
@@ -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,
+13 -74
View File
@@ -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<string | undefined>(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<string, AgentModelDefinition[]>();
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();
+13 -11
View File
@@ -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,
});
+8 -3
View File
@@ -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<DaemonClient, "openProject"> | null;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => 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);
},
@@ -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<string, AgentModelDefinition[]>();
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 };
}
-250
View File
@@ -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<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<View style={styles.container}>
<View style={[styles.content, styles.loadingContent]}>
<Text style={styles.errorText}>
{workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message}
</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView
contentContainerStyle={[
styles.content,
!isPaneFocused ? styles.contentUnfocused : null,
]}
keyboardShouldPersistTaps="handled"
>
<View style={styles.inner}>
<View style={styles.primaryRow}>
<LauncherTile
title="New Chat"
Icon={SquarePen}
accent
disabled={actionsDisabled}
pending={pendingAction === "draft"}
onPress={openDraftTab}
/>
<LauncherTile
title="Terminal"
Icon={SquareTerminal}
disabled={actionsDisabled}
pending={pendingAction === "terminal"}
onPress={() => {
void openTerminalTab();
}}
/>
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</ScrollView>
</View>
);
}
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 (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.primaryTile,
accent ? styles.primaryTileAccent : null,
(hovered || pressed) && !disabled
? accent
? styles.primaryTileAccentInteractive
: styles.tileInteractive
: null,
disabled ? styles.tileDisabled : null,
]}
>
<View style={[styles.primaryIconWrap, accent ? styles.primaryIconWrapAccent : null]}>
{pending ? (
<ActivityIndicator
size="small"
color={accent ? theme.colors.accentForeground : theme.colors.foreground}
/>
) : (
<Icon size={16} color={iconColor} />
)}
</View>
<Text style={[styles.primaryTileTitle, { color: titleColor }]}>{title}</Text>
</Pressable>
);
}
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,
},
}));
@@ -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;
}
@@ -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}
@@ -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)}`;
}
@@ -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: {
@@ -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", () => {
@@ -21,7 +21,6 @@ import {
insertSplit,
moveTabToPaneInLayout,
normalizeLayout,
openLauncherTabInLayout,
openTabInLayout,
reconcileWorkspaceTabs,
removePaneFromTree,
@@ -67,7 +66,6 @@ interface WorkspaceLayoutStore {
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
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<WorkspaceLayoutStore>()(
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);
@@ -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),
},
]);
@@ -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<WorkspaceTabsState>()(
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) {
@@ -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;
+7
View File
@@ -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