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, type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native"; 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) => ({ const styles = StyleSheet.create((theme) => ({
desktopOverlay: { desktopOverlay: {
@@ -32,16 +34,21 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.xl, borderRadius: theme.borderRadius.xl,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.surface2, borderColor: theme.colors.surface2,
overflow: "hidden",
}, },
header: { header: {
paddingHorizontal: theme.spacing[6], paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
justifyContent: "space-between", justifyContent: "space-between",
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2, borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
headerTitleGroup: {
flex: 1,
gap: theme.spacing[2],
minWidth: 0,
}, },
title: { title: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -69,9 +76,10 @@ const styles = StyleSheet.create((theme) => ({
paddingBottom: theme.spacing[3], paddingBottom: theme.spacing[3],
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "flex-start",
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2, borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
}, },
bottomSheetContent: { bottomSheetContent: {
padding: theme.spacing[6], padding: theme.spacing[6],
@@ -97,22 +105,31 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
export interface AdaptiveModalSheetProps { export interface AdaptiveModalSheetProps {
title: string; title: string;
/** Optional content rendered below the title in the header area. */
subtitle?: ReactNode;
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
snapPoints?: string[]; snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace"; stackBehavior?: "push" | "switch" | "replace";
testID?: string; 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({ export function AdaptiveModalSheet({
title, title,
subtitle,
visible, visible,
onClose, onClose,
children, children,
snapPoints, snapPoints,
stackBehavior, stackBehavior,
testID, testID,
desktopMaxWidth,
onFilesDropped,
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -120,6 +137,19 @@ export function AdaptiveModalSheet({
const dismissingForVisibilityRef = useRef(false); const dismissingForVisibilityRef = useRef(false);
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); 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(() => { useEffect(() => {
if (!isMobile) return; if (!isMobile) return;
if (visible) { if (visible) {
@@ -168,7 +198,10 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> <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}> <Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} /> <X size={16} color={theme.colors.foregroundMuted} />
</Pressable> </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 = ( const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}> <View style={styles.desktopOverlay} testID={testID}>
<Pressable <Pressable
@@ -191,21 +246,12 @@ export function AdaptiveModalSheet({
style={{ ...StyleSheet.absoluteFillObject }} style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose} onPress={onClose}
/> />
<View style={styles.desktopCard}> <View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
<View style={styles.header}> {onFilesDropped ? (
<Text style={styles.title}>{title}</Text> <FileDropZone onFilesDropped={onFilesDropped}>
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}> {cardInner}
<X size={16} color={theme.colors.foregroundMuted} /> </FileDropZone>
</Pressable> ) : cardInner}
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View> </View>
</View> </View>
); );
@@ -6,7 +6,7 @@ import { useStoreWithEqualityFn } from "zustand/traditional";
import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native"; import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; 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 { useSessionStore } from "@/stores/session-store";
import { import {
buildFavoriteModelKey, buildFavoriteModelKey,
@@ -39,7 +39,7 @@ import {
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = { type StatusOption = {
id: string; id: string;
@@ -626,52 +626,15 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
); );
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const modelsQuery = useQuery({ const { allProviderModels: providerModelsMap, isLoading: isProviderModelsLoading } =
queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"], useProviderModels(serverId);
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 agentProviderDefinitions = useMemo(() => { const agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : []; return definition ? [definition] : [];
}, [agent?.provider]); }, [agent?.provider]);
const agentProviderModelQuery = useQuery({ const models = agent?.provider ? (providerModelsMap.get(agent.provider) ?? null) : null;
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 displayMode = const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -719,7 +682,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
} }
selectedModeId={agent.currentModeId ?? undefined} selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions} providerDefinitions={agentProviderDefinitions}
allProviderModels={agentProviderModels} allProviderModels={providerModelsMap}
onSelectMode={(modeId) => { onSelectMode={(modeId) => {
if (!client) { if (!client) {
return; return;
@@ -782,7 +745,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
}); });
}} }}
isModelLoading={isProviderModelsQueryLoading(modelsQuery)} isModelLoading={isProviderModelsLoading}
disabled={!client} disabled={!client}
/> />
); );
@@ -68,6 +68,7 @@ interface SelectorContentProps {
onToggleFavorite?: (provider: string, modelId: string) => void; onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void;
onBack?: () => void; onBack?: () => void;
isLoading?: boolean;
} }
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
@@ -338,6 +339,7 @@ function SelectorContent({
onToggleFavorite, onToggleFavorite,
onDrillDown, onDrillDown,
onBack, onBack,
isLoading,
}: SelectorContentProps) { }: SelectorContentProps) {
const allRows = useMemo( const allRows = useMemo(
() => buildModelRows(providerDefinitions, allProviderModels), () => buildModelRows(providerDefinitions, allProviderModels),
@@ -404,8 +406,17 @@ function SelectorContent({
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( {favoriteRows.length === 0 && groupedRegularRows.length === 0 ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<Search size={16} color="#777" /> {isLoading ? (
<Text style={styles.emptyStateText}>No models match your search</Text> <>
<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> </View>
) : null} ) : null}
</View> </View>
@@ -583,6 +594,7 @@ export function CombinedModelSelector({
onSelect={handleSelect} onSelect={handleSelect}
canSelectProvider={canSelectProvider} canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
isLoading={isLoading}
onDrillDown={(providerId, providerLabel) => { onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel }); setView({ kind: "provider", providerId, providerLabel });
}} }}
+4
View File
@@ -84,6 +84,8 @@ interface ComposerProps {
onAttentionPromptSend?: () => void; onAttentionPromptSend?: () => void;
/** Controlled status controls rendered in input area (draft flows). */ /** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps; statusControls?: DraftAgentStatusBarProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
} }
const EMPTY_ARRAY: readonly QueuedMessage[] = []; const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -111,6 +113,7 @@ export function Composer({
onAttentionInputFocus, onAttentionInputFocus,
onAttentionPromptSend, onAttentionPromptSend,
statusControls, statusControls,
inputWrapperStyle,
}: ComposerProps) { }: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`); markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -711,6 +714,7 @@ export function Composer({
} }
}} }}
onHeightChange={onComposerHeightChange} onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
/> />
</View> </View>
</View> </View>
@@ -96,6 +96,8 @@ export interface MessageInputProps {
onSelectionChange?: (selection: { start: number; end: number }) => void; onSelectionChange?: (selection: { start: number; end: number }) => void;
onFocusChange?: (focused: boolean) => void; onFocusChange?: (focused: boolean) => void;
onHeightChange?: (height: number) => void; onHeightChange?: (height: number) => void;
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
} }
export interface MessageInputRef { export interface MessageInputRef {
@@ -213,6 +215,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChange: onSelectionChangeCallback, onSelectionChange: onSelectionChangeCallback,
onFocusChange, onFocusChange,
onHeightChange, onHeightChange,
inputWrapperStyle,
}, },
ref, ref,
) { ) {
@@ -902,7 +905,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return ( return (
<View ref={rootRef} style={styles.container} testID="message-input-root"> <View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */} {/* Regular input */}
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}> <Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}>
{/* Image preview pills */} {/* Image preview pills */}
{hasImages && ( {hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview"> <View style={styles.imagePreviewContainer} testID="message-input-image-preview">
@@ -87,7 +87,8 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (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: { buildPaneContentModel: (input: {
paneId: string; paneId: string;
isPaneFocused: boolean; isPaneFocused: boolean;
@@ -263,7 +264,8 @@ export function SplitContainer({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onCreateLauncherTab, onCreateDraftTab,
onCreateTerminalTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
@@ -530,7 +532,8 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateLauncherTab={onCreateLauncherTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
@@ -650,7 +653,8 @@ function SplitNodeView({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onCreateLauncherTab, onCreateDraftTab,
onCreateTerminalTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
@@ -683,7 +687,8 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateLauncherTab={onCreateLauncherTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
@@ -731,7 +736,8 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight} onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs} onCloseOtherTabs={onCloseOtherTabs}
onCreateLauncherTab={onCreateLauncherTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
buildPaneContentModel={buildPaneContentModel} buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane} onFocusPane={onFocusPane}
onSplitPane={onSplitPane} onSplitPane={onSplitPane}
@@ -778,7 +784,8 @@ function SplitPaneView({
onCloseTabsToLeft, onCloseTabsToLeft,
onCloseTabsToRight, onCloseTabsToRight,
onCloseOtherTabs, onCloseOtherTabs,
onCreateLauncherTab, onCreateDraftTab,
onCreateTerminalTab,
buildPaneContentModel, buildPaneContentModel,
onFocusPane, onFocusPane,
onSplitPane, onSplitPane,
@@ -887,7 +894,8 @@ function SplitPaneView({
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)} onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onCreateLauncherTab={onCreateLauncherTab} onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onReorderTabs={(nextTabs) => { onReorderTabs={(nextTabs) => {
onReorderTabsInPane( onReorderTabsInPane(
pane.id, pane.id,
@@ -1,23 +1,32 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native"; import { Image, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { import {
requireWorkspaceExecutionAuthority, requireWorkspaceExecutionAuthority,
requireWorkspaceRecordId, requireWorkspaceRecordId,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; 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() { export function WorkspaceSetupDialog() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -31,7 +40,7 @@ export function WorkspaceSetupDialog() {
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType< const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor typeof normalizeWorkspaceDescriptor
> | null>(null); > | null>(null);
const [pendingAction, setPendingAction] = useState<"chat" | "terminal" | null>(null); const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const serverId = pendingWorkspaceSetup?.serverId ?? ""; const serverId = pendingWorkspaceSetup?.serverId ?? "";
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
@@ -48,7 +57,7 @@ export function WorkspaceSetupDialog() {
: undefined, : undefined,
isVisible: pendingWorkspaceSetup !== null, isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [], onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspace?.workspaceDirectory || undefined, lockedWorkingDir: workspace?.workspaceDirectory || sourceDirectory || undefined,
}, },
}); });
const composerState = chatDraft.composerState; const composerState = chatDraft.composerState;
@@ -56,6 +65,12 @@ export function WorkspaceSetupDialog() {
throw new Error("Workspace setup composer state is required"); throw new Error("Workspace setup composer state is required");
} }
const { icon: projectIcon } = useProjectIconQuery({
serverId,
cwd: sourceDirectory,
});
const iconDataUri = toProjectIconDataUri(projectIcon);
useEffect(() => { useEffect(() => {
setErrorMessage(null); setErrorMessage(null);
setCreatedWorkspace(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 = const workspaceTitle =
workspace?.name || workspace?.name ||
@@ -243,25 +230,53 @@ export function WorkspaceSetupDialog() {
displayName || displayName ||
sourceDirectory.split(/[\\/]/).filter(Boolean).pop() || sourceDirectory.split(/[\\/]/).filter(Boolean).pop() ||
sourceDirectory; 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) { if (!pendingWorkspaceSetup || !sourceDirectory) {
return null; 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 ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Set up workspace" title="Create workspace"
subtitle={subtitleContent}
visible={true} visible={true}
onClose={handleClose} onClose={handleClose}
snapPoints={["82%", "94%"]} snapPoints={["82%", "94%"]}
testID="workspace-setup-dialog" 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}> <View style={styles.section}>
<Composer <Composer
agentId={`workspace-setup:${serverId}:${sourceDirectory}`} agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
@@ -285,55 +300,48 @@ export function WorkspaceSetupDialog() {
} }
: undefined : undefined
} }
inputWrapperStyle={composerInputWrapperStyle}
onAddImages={handleAddImagesCallback}
/> />
</View> </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} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet> </AdaptiveModalSheet>
); );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
header: { subtitleRow: {
gap: theme.spacing[1], flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
}, },
workspaceTitle: { projectIcon: {
fontSize: theme.fontSize.base, width: theme.iconSize.md,
fontWeight: theme.fontWeight.medium, height: theme.iconSize.md,
color: theme.colors.foreground, borderRadius: theme.borderRadius.sm,
}, },
workspacePath: { projectIconFallback: {
fontSize: theme.fontSize.xs, 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, color: theme.colors.foregroundMuted,
}, },
section: { section: {
gap: theme.spacing[3], gap: theme.spacing[3],
marginHorizontal: -theme.spacing[6], marginHorizontal: -theme.spacing[6],
}, marginVertical: -theme.spacing[2],
terminalLink: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-end",
gap: theme.spacing[2],
},
terminalLinkText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
}, },
errorText: { errorText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -279,52 +279,52 @@ describe("processAgentStreamEvent", () => {
expect(result.cursorChanged).toBe(false); expect(result.cursorChanged).toBe(false);
}); });
it("appends committed live rows to tail and clears superseded provisional assistant state", () => { it("keeps tool call rows anchored in tail across live and committed updates", () => {
const currentHead: StreamItem[] = [ const running = processAgentStreamEvent({
{
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({
...baseStreamInput, ...baseStreamInput,
event: makeToolCallEvent("running"), event: makeToolCallEvent("running"),
seq: undefined, 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({ const committed = processAgentStreamEvent({
...baseStreamInput, ...baseStreamInput,
event: makeToolCallEvent("completed"), event: makeToolCallEvent("completed"),
seq: 8, seq: 8,
currentHead: provisional.head, currentHead: completedLive.head,
currentTail: provisional.tail, currentTail: completedLive.tail,
currentCursor: { startSeq: 1, endSeq: 7 }, 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", () => { it("requests catch-up when a committed live row skips ahead", () => {
const result = processAgentStreamEvent({ const result = processAgentStreamEvent({
...baseStreamInput, ...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({ const result = processAgentStreamEvent({
...baseStreamInput, ...baseStreamInput,
event: { event: {
type: "turn_completed", type: "turn_completed",
provider: "claude", provider: "claude",
}, },
currentHead: [ currentHead: withHead.head,
{ currentTail: withHead.tail,
kind: "thought",
id: "reasoning-1",
text: "thinking",
timestamp: new Date(1000),
status: "loading",
},
],
}); });
expect(result.changedHead).toBe(true); expect(result.changedHead).toBe(true);
expect(result.changedTail).toBe(false); expect(result.changedTail).toBe(true);
expect(result.head).toEqual([]); 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 { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import { import {
classifySessionTimelineSeq, classifySessionTimelineSeq,
type SessionTimelineSeqDecision, type SessionTimelineSeqDecision,
@@ -289,10 +289,7 @@ export function processAgentStreamEvent(
): ProcessAgentStreamEventOutput { ): ProcessAgentStreamEventOutput {
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input; const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
let nextTail = currentTail; let shouldApplyStreamEvent = true;
let nextHead = currentHead;
let changedTail = false;
let changedHead = false;
let nextTimelineCursor: TimelineCursor | null = null; let nextTimelineCursor: TimelineCursor | null = null;
let cursorChanged = false; let cursorChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = []; const sideEffects: AgentStreamReducerSideEffect[] = [];
@@ -304,42 +301,39 @@ export function processAgentStreamEvent(
}); });
if (decision === "gap") { if (decision === "gap") {
shouldApplyStreamEvent = false;
if (currentCursor) { if (currentCursor) {
sideEffects.push({ sideEffects.push({
type: "catch_up", type: "catch_up",
cursor: { endSeq: currentCursor.endSeq }, cursor: { endSeq: currentCursor.endSeq },
}); });
} }
} else if (decision !== "drop_stale") { } else if (decision === "drop_stale") {
nextTail = reduceStreamUpdate(currentTail, event, timestamp, { shouldApplyStreamEvent = false;
source: "canonical", } else {
});
changedTail = nextTail !== currentTail;
nextHead = removeSupersededProvisionalItems(currentHead, event);
changedHead = nextHead !== currentHead;
nextTimelineCursor = nextTimelineCursor =
decision === "init" decision === "init"
? { startSeq: seq, endSeq: seq } ? { startSeq: seq, endSeq: seq }
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq }; : { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor); 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 agentPatch: AgentPatch | null = null;
let agentChanged = false; let agentChanged = false;
@@ -366,8 +360,8 @@ export function processAgentStreamEvent(
} }
return { return {
tail: nextTail, tail,
head: nextHead, head,
changedTail, changedTail,
changedHead, changedHead,
cursor: nextTimelineCursor, cursor: nextTimelineCursor,
+13 -74
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueries } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AGENT_PROVIDER_DEFINITIONS, AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition, type AgentProviderDefinition,
@@ -11,6 +11,7 @@ import type {
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime"; import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProviderModels } from "@/hooks/use-provider-models";
import { import {
useFormPreferences, useFormPreferences,
mergeProviderPreferences, mergeProviderPreferences,
@@ -409,75 +410,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[providerDefinitions], [providerDefinitions],
); );
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined); const { allProviderModels, isLoading: isAllModelsLoading } = useProviderModels(
useEffect(() => { formState.serverId ?? "",
const trimmed = formState.workingDir.trim(); );
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const providerModelsQuery = useQuery({ const availableModels = allProviderModels.get(formState.provider) ?? null;
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);
// Combine initialValues with initialServerId for resolution // Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => { const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -687,9 +624,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setFormState((prev) => ({ ...prev, serverId: value })); setFormState((prev) => ({ ...prev, serverId: value }));
}, []); }, []);
const queryClient = useQueryClient();
const refreshProviderModels = useCallback(() => { const refreshProviderModels = useCallback(() => {
void providerModelsQuery.refetch(); void queryClient.invalidateQueries({
}, [providerModelsQuery]); queryKey: ["providerModels", formState.serverId, formState.provider],
});
}, [queryClient, formState.serverId, formState.provider]);
const persistFormPreferences = useCallback(async () => { const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
@@ -726,9 +666,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model; const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; const isModelLoading = !availableModels && isAllModelsLoading;
const modelError = const modelError: string | null = null;
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
const workingDirIsEmpty = !formState.workingDir.trim(); const workingDirIsEmpty = !formState.workingDir.trim();
+13 -11
View File
@@ -32,10 +32,19 @@ import {
collectAllTabs, collectAllTabs,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/project"; const WORKSPACE_ID = "/repo/project";
function createOpenDraftTab() {
return (workspaceKey: string) =>
useWorkspaceLayoutStore.getState().openTab(workspaceKey, {
kind: "draft",
draftId: generateDraftId(),
});
}
describe("openProjectDirectly", () => { describe("openProjectDirectly", () => {
beforeEach(() => { beforeEach(() => {
replaceRoute.mockReset(); replaceRoute.mockReset();
@@ -51,11 +60,7 @@ describe("openProjectDirectly", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("opens the workspace directly, marks workspaces hydrated, and seeds a launcher tab", async () => { it("opens the workspace directly, marks workspaces hydrated, and seeds a draft tab", async () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"11111111-1111-1111-1111-111111111111",
);
const result = await openProjectDirectly({ const result = await openProjectDirectly({
serverId: SERVER_ID, serverId: SERVER_ID,
projectPath: WORKSPACE_ID, projectPath: WORKSPACE_ID,
@@ -82,7 +87,7 @@ describe("openProjectDirectly", () => {
}, },
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, openDraftTab: createOpenDraftTab(),
replaceRoute, replaceRoute,
}); });
@@ -106,10 +111,7 @@ describe("openProjectDirectly", () => {
expect(layout.root.kind).toBe("pane"); expect(layout.root.kind).toBe("pane");
const tabs = collectAllTabs(layout.root); const tabs = collectAllTabs(layout.root);
expect(tabs).toHaveLength(1); expect(tabs).toHaveLength(1);
expect(tabs[0]?.target).toEqual({ expect(tabs[0]?.target.kind).toBe("draft");
kind: "launcher",
launcherId: "11111111-1111-1111-1111-111111111111",
});
expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ"); expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ");
}); });
@@ -127,7 +129,7 @@ describe("openProjectDirectly", () => {
}, },
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, openDraftTab: createOpenDraftTab(),
replaceRoute, replaceRoute,
}); });
+8 -3
View File
@@ -7,6 +7,7 @@ import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore, useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store"; } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { buildHostWorkspaceRoute } from "@/utils/host-routes";
interface OpenProjectDirectlyInput { interface OpenProjectDirectlyInput {
@@ -16,7 +17,7 @@ interface OpenProjectDirectlyInput {
client: Pick<DaemonClient, "openProject"> | null; client: Pick<DaemonClient, "openProject"> | null;
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void; mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
openLauncherTab: (workspaceKey: string) => string | null; openDraftTab: (workspaceKey: string) => string | null;
replaceRoute: (route: string) => void; replaceRoute: (route: string) => void;
} }
@@ -44,7 +45,7 @@ export async function openProjectDirectly(input: OpenProjectDirectlyInput): Prom
return false; return false;
} }
input.openLauncherTab(workspaceKey); input.openDraftTab(workspaceKey);
input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id)); input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id));
return true; return true;
} }
@@ -65,7 +66,11 @@ export function useOpenProject(serverId: string | null): (path: string) => Promi
client, client,
mergeWorkspaces, mergeWorkspaces,
setHasHydratedWorkspaces, setHasHydratedWorkspaces,
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab, openDraftTab: (workspaceKey: string) =>
useWorkspaceLayoutStore.getState().openTab(workspaceKey, {
kind: "draft",
draftId: generateDraftId(),
}),
replaceRoute: (route) => { replaceRoute: (route) => {
router.replace(route as any); 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 { agentPanelRegistration } from "@/panels/agent-panel";
import { draftPanelRegistration } from "@/panels/draft-panel"; import { draftPanelRegistration } from "@/panels/draft-panel";
import { filePanelRegistration } from "@/panels/file-panel"; import { filePanelRegistration } from "@/panels/file-panel";
import { launcherPanelRegistration } from "@/panels/launcher-panel";
import { registerPanel } from "@/panels/panel-registry"; import { registerPanel } from "@/panels/panel-registry";
import { setupPanelRegistration } from "@/panels/setup-panel"; import { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel";
@@ -17,6 +16,5 @@ export function ensurePanelsRegistered(): void {
registerPanel(setupPanelRegistration); registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration); registerPanel(terminalPanelRegistration);
registerPanel(filePanelRegistration); registerPanel(filePanelRegistration);
registerPanel(launcherPanelRegistration);
panelsRegistered = true; panelsRegistered = true;
} }
@@ -67,6 +67,7 @@ import {
workspaceTabTargetsEqual, workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity"; } from "@/utils/workspace-tab-identity";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; 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 { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import { import {
checkoutStatusQueryKey, checkoutStatusQueryKey,
@@ -143,9 +144,6 @@ function decodeSegment(value: string): string {
} }
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "launcher") {
return "New Tab";
}
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
return "New Agent"; return "New Agent";
} }
@@ -162,9 +160,6 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
} }
function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string { function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "launcher") {
return "New Tab";
}
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
return "New Agent"; return "New Agent";
} }
@@ -593,6 +588,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
// Prefetch provider models early so the model picker is warm by the time it opens
useProviderModels(normalizedServerId);
const normalizedWorkspaceId = const normalizedWorkspaceId =
resolveWorkspaceRouteId({ resolveWorkspaceRouteId({
routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId), routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId),
@@ -853,7 +852,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[workspaceLayout], [workspaceLayout],
); );
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab); const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
const openWorkspaceLauncherTab = useWorkspaceLayoutStore((state) => state.openLauncherTab);
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
@@ -1107,27 +1105,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[tabs], [tabs],
); );
const handleCreateDraftTab = useCallback(() => { const handleCreateDraftTab = useCallback(
openWorkspaceDraftTab();
}, [openWorkspaceDraftTab]);
const handleCreateLauncherTab = useCallback(
(input?: { paneId?: string }) => { (input?: { paneId?: string }) => {
if (!persistenceKey) { if (input?.paneId && persistenceKey) {
return null;
}
if (input?.paneId) {
focusWorkspacePane(persistenceKey, input.paneId); focusWorkspacePane(persistenceKey, input.paneId);
} }
openWorkspaceDraftTab();
const tabId = openWorkspaceLauncherTab(persistenceKey);
if (tabId) {
focusWorkspaceTab(persistenceKey, tabId);
}
return tabId;
}, },
[focusWorkspacePane, focusWorkspaceTab, openWorkspaceLauncherTab, persistenceKey], [focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
); );
const handleCreateTerminal = useCallback( const handleCreateTerminal = useCallback(
@@ -1150,7 +1135,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[navigateToTabId], [navigateToTabId],
); );
const handleCreateLauncherSplit = useCallback( const handleCreateDraftSplit = useCallback(
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => { (input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
if (!persistenceKey) { if (!persistenceKey) {
return; return;
@@ -1161,9 +1146,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return; return;
} }
handleCreateLauncherTab({ paneId }); handleCreateDraftTab({ paneId });
}, },
[handleCreateLauncherTab, persistenceKey, splitWorkspacePaneEmpty], [handleCreateDraftTab, persistenceKey, splitWorkspacePaneEmpty],
); );
const killTerminalAsync = killTerminalMutation.mutateAsync; const killTerminalAsync = killTerminalMutation.mutateAsync;
@@ -1493,7 +1478,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
(action: KeyboardActionDefinition): boolean => { (action: KeyboardActionDefinition): boolean => {
switch (action.id) { switch (action.id) {
case "workspace.tab.new": case "workspace.tab.new":
handleCreateLauncherTab(); handleCreateDraftTab();
return true; return true;
case "workspace.terminal.new": case "workspace.terminal.new":
handleCreateTerminal(); handleCreateTerminal();
@@ -1529,7 +1514,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
[ [
activeTabId, activeTabId,
handleCloseTabById, handleCloseTabById,
handleCreateLauncherTab, handleCreateDraftTab,
handleCreateTerminal, handleCreateTerminal,
navigateToTabId, navigateToTabId,
tabs, tabs,
@@ -1548,7 +1533,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
} }
if (action.id === "workspace.pane.split.right") { if (action.id === "workspace.pane.split.right") {
handleCreateLauncherSplit({ handleCreateDraftSplit({
targetPaneId: focusedPane.id, targetPaneId: focusedPane.id,
position: "right", position: "right",
}); });
@@ -1556,7 +1541,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
} }
if (action.id === "workspace.pane.split.down") { if (action.id === "workspace.pane.split.down") {
handleCreateLauncherSplit({ handleCreateDraftSplit({
targetPaneId: focusedPane.id, targetPaneId: focusedPane.id,
position: "bottom", position: "bottom",
}); });
@@ -1626,7 +1611,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
allTabDescriptorsById, allTabDescriptorsById,
closeWorkspaceTabWithCleanup, closeWorkspaceTabWithCleanup,
focusWorkspacePane, focusWorkspacePane,
handleCreateLauncherSplit, handleCreateDraftSplit,
moveWorkspaceTabToPane, moveWorkspaceTabToPane,
persistenceKey, persistenceKey,
focusedPaneTabState.activeTabId, focusedPaneTabState.activeTabId,
@@ -2173,7 +2158,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight} onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs} onCloseOtherTabs={handleCloseOtherTabs}
onCreateLauncherTab={handleCreateLauncherTab} onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
onReorderTabs={handleReorderTabsInFocusedPane} onReorderTabs={handleReorderTabsInFocusedPane}
onSplitRight={() => {}} onSplitRight={() => {}}
onSplitDown={() => {}} onSplitDown={() => {}}
@@ -2207,11 +2193,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToLeft={handleCloseTabsToLeftInPane}
onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseTabsToRight={handleCloseTabsToRightInPane}
onCloseOtherTabs={handleCloseOtherTabsInPane} onCloseOtherTabs={handleCloseOtherTabsInPane}
onCreateLauncherTab={handleCreateLauncherTab} onCreateDraftTab={handleCreateDraftTab}
onCreateTerminalTab={handleCreateTerminal}
buildPaneContentModel={buildDesktopPaneContentModel} buildPaneContentModel={buildDesktopPaneContentModel}
onFocusPane={handleFocusPane} onFocusPane={handleFocusPane}
onSplitPane={handleSplitPane} onSplitPane={handleSplitPane}
onSplitPaneEmpty={handleCreateLauncherSplit} onSplitPaneEmpty={handleCreateDraftSplit}
onMoveTabToPane={handleMoveTabToPane} onMoveTabToPane={handleMoveTabToPane}
onResizeSplit={handleResizePaneSplit} onResizeSplit={handleResizePaneSplit}
onReorderTabsInPane={handleReorderTabsInPane} onReorderTabsInPane={handleReorderTabsInPane}
@@ -78,9 +78,6 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
return `workspace-draft-close-${tab.target.draftId}`; return `workspace-draft-close-${tab.target.draftId}`;
} }
if (tab.target.kind === "launcher") {
return `workspace-launcher-close-${tab.target.launcherId}`;
}
if (tab.target.kind === "setup") { if (tab.target.kind === "setup") {
return `workspace-setup-close-${encodeFilePathForPathSegment(tab.target.workspaceId)}`; 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 type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
createLauncherId,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual, workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity"; } from "@/utils/workspace-tab-identity";
@@ -111,10 +110,6 @@ interface OpenTabInLayoutResult {
tabId: string; tabId: string;
} }
interface OpenLauncherTabInLayoutInput {
layout: WorkspaceLayout;
now: number;
}
interface RetargetTabInLayoutInput { interface RetargetTabInLayoutInput {
layout: WorkspaceLayout; layout: WorkspaceLayout;
@@ -1070,19 +1065,6 @@ export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutRes
return insertNewTabIntoFocusedPane(input); 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 { export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
const internalLayout = asInternalLayout(input.layout); const internalLayout = asInternalLayout(input.layout);
const pane = findPaneContainingTab(internalLayout.root, input.tabId); const pane = findPaneContainingTab(internalLayout.root, input.tabId);
@@ -1159,7 +1141,7 @@ export function retargetTabInLayout(
} }
return { 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. // React key during the first render. Reconciliation can canonicalize later.
tabId: input.tabId, tabId: input.tabId,
layout: { layout: {
@@ -262,36 +262,33 @@ describe("workspace-layout-store actions", () => {
]); ]);
}); });
it("openLauncherTab creates duplicate launcher tabs for repeated Cmd+T/new-tab opens", () => { it("openTab creates distinct draft 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");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstTabId = store.openLauncherTab(workspaceKey); const firstTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-1" });
const secondTabId = store.openLauncherTab(workspaceKey); const secondTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-2" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111"); expect(firstTabId).toBe("draft-1");
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222"); expect(secondTabId).toBe("draft-2");
expect(firstTabId).not.toBe(secondTabId); expect(firstTabId).not.toBe(secondTabId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]); expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]);
expect(collectAllTabs(layout.root)).toEqual([ expect(collectAllTabs(layout.root)).toEqual([
{ {
tabId: firstTabId, tabId: firstTabId,
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" }, target: { kind: "draft", draftId: "draft-1" },
createdAt: expect.any(Number), createdAt: expect.any(Number),
}, },
{ {
tabId: secondTabId, tabId: secondTabId,
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" }, target: { kind: "draft", draftId: "draft-2" },
createdAt: expect.any(Number), 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( vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
"77777777-7777-7777-7777-777777777777", "77777777-7777-7777-7777-777777777777",
); );
@@ -303,15 +300,15 @@ describe("workspace-layout-store actions", () => {
targetPaneId: "main", targetPaneId: "main",
position: "right", position: "right",
}); });
const launcherTabId = store.openLauncherTab(workspaceKey); const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-split" });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777"); expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
expect(launcherTabId).toMatch(/^launcher_/); expect(draftTabId).toBe("draft-split");
expect(layout.focusedPaneId).toBe(newPaneId); expect(layout.focusedPaneId).toBe(newPaneId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]); 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!)?.tabIds).toEqual([draftTabId!]);
expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(launcherTabId); expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(draftTabId);
}); });
it("focusTab moves workspace focus to the pane containing the tab", () => { 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", () => { it("retargetTab keeps a draft tab in place while updating its target", () => {
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
"33333333-3333-3333-3333-333333333333",
);
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const launcherTabId = store.openLauncherTab(workspaceKey); const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-retarget" });
const nextTabId = store.retargetTab(workspaceKey, launcherTabId!, { const nextTabId = store.retargetTab(workspaceKey, draftTabId!, {
kind: "file", kind: "file",
path: "/repo/worktree/launcher.ts", path: "/repo/worktree/retargeted.ts",
}); });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(launcherTabId).toBe("launcher_33333333-3333-3333-3333-333333333333"); expect(draftTabId).toBe("draft-retarget");
expect(nextTabId).toBe(launcherTabId); expect(nextTabId).toBe(draftTabId);
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([launcherTabId!]); expect(findPaneById(layout.root, "main")?.tabIds).toEqual([draftTabId!]);
expect(collectAllTabs(layout.root)).toEqual([ expect(collectAllTabs(layout.root)).toEqual([
{ {
tabId: launcherTabId!, tabId: draftTabId!,
target: { kind: "file", path: "/repo/worktree/launcher.ts" }, target: { kind: "file", path: "/repo/worktree/retargeted.ts" },
createdAt: expect.any(Number), 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") vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444") .mockReturnValueOnce("55555555-5555-5555-5555-555555555555");
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
@@ -409,64 +401,59 @@ describe("workspace-layout-store actions", () => {
kind: "file", kind: "file",
path: "/repo/worktree/existing.ts", 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, { const splitPaneId = store.splitPane(workspaceKey, {
tabId: launcherTabId!, tabId: draftTabId!,
targetPaneId: "main", targetPaneId: "main",
position: "right", 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", kind: "file",
path: "/repo/worktree/existing.ts", path: "/repo/worktree/existing.ts",
}); });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts"); 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(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555");
expect(secondLauncherTabId).toMatch(/^launcher_/);
expect(secondLauncherTabId).not.toBe(launcherTabId);
expect(nextTabId).toBe(existingFileTabId); expect(nextTabId).toBe(existingFileTabId);
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([ expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
existingFileTabId!, existingFileTabId!,
launcherTabId!, draftTabId!,
]); ]);
expect(layout.focusedPaneId).toBe("main"); expect(layout.focusedPaneId).toBe("main");
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId); expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId);
}); });
it("retargetTab closes a launcher tab and focuses an existing matching target tab", () => { it("retargetTab closes a draft 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");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
const store = useWorkspaceLayoutStore.getState(); const store = useWorkspaceLayoutStore.getState();
const firstLauncherTabId = store.openLauncherTab(workspaceKey); const firstDraftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-agent-1" });
const firstAgentTabId = store.retargetTab(workspaceKey, firstLauncherTabId!, { const firstAgentTabId = store.retargetTab(workspaceKey, firstDraftTabId!, {
kind: "agent", kind: "agent",
agentId: "agent-1", 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", kind: "agent",
agentId: "agent-1", agentId: "agent-1",
}); });
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!; const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
expect(firstAgentTabId).toBe(firstLauncherTabId); expect(firstAgentTabId).toBe(firstDraftTabId);
expect(nextTabId).toBe(firstLauncherTabId); expect(nextTabId).toBe(firstDraftTabId);
expect(collectAllTabs(layout.root)).toEqual([ expect(collectAllTabs(layout.root)).toEqual([
{ {
tabId: firstLauncherTabId!, tabId: firstDraftTabId!,
target: { kind: "agent", agentId: "agent-1" }, target: { kind: "agent", agentId: "agent-1" },
createdAt: expect.any(Number), 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", () => { it("reorderTabs reorders tabs within the focused pane", () => {
@@ -21,7 +21,6 @@ import {
insertSplit, insertSplit,
moveTabToPaneInLayout, moveTabToPaneInLayout,
normalizeLayout, normalizeLayout,
openLauncherTabInLayout,
openTabInLayout, openTabInLayout,
reconcileWorkspaceTabs, reconcileWorkspaceTabs,
removePaneFromTree, removePaneFromTree,
@@ -67,7 +66,6 @@ interface WorkspaceLayoutStore {
splitSizesByWorkspace: Record<string, Record<string, number[]>>; splitSizesByWorkspace: Record<string, Record<string, number[]>>;
pinnedAgentIdsByWorkspace: Record<string, Set<string>>; pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
openLauncherTab: (workspaceKey: string) => string | null;
closeTab: (workspaceKey: string, tabId: string) => void; closeTab: (workspaceKey: string, tabId: string) => void;
focusTab: (workspaceKey: string, tabId: string) => void; focusTab: (workspaceKey: string, tabId: string) => void;
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null; retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
@@ -143,26 +141,6 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
return result.tabId; 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) => { closeTab: (workspaceKey, tabId) => {
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
const normalizedTabId = trimNonEmpty(tabId); const normalizedTabId = trimNonEmpty(tabId);
@@ -140,36 +140,35 @@ describe("workspace-tabs-store retargetTab", () => {
expect(order).toEqual([draftTabId]); expect(order).toEqual([draftTabId]);
}); });
it("openLauncherTab creates distinct launcher tabs without deduplicating", () => { it("openDraftTab creates a draft tab and deduplicates by draftId", () => {
vi.spyOn(globalThis.crypto, "randomUUID")
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy(); expect(key).toBeTruthy();
const workspaceKey = key as string; const workspaceKey = key as string;
const firstTabId = useWorkspaceTabsStore.getState().openLauncherTab({ const firstTabId = useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
draftId: "draft-1",
}); });
const secondTabId = useWorkspaceTabsStore.getState().openLauncherTab({ const secondTabId = useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
draftId: "draft-2",
}); });
const state = useWorkspaceTabsStore.getState(); const state = useWorkspaceTabsStore.getState();
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111"); expect(firstTabId).toBe("draft-1");
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222"); expect(secondTabId).toBe("draft-2");
expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]); expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]);
expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([ expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([
{ {
tabId: "launcher_11111111-1111-1111-1111-111111111111", tabId: "draft-1",
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" }, target: { kind: "draft", draftId: "draft-1" },
createdAt: expect.any(Number), createdAt: expect.any(Number),
}, },
{ {
tabId: "launcher_22222222-2222-2222-2222-222222222222", tabId: "draft-2",
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" }, target: { kind: "draft", draftId: "draft-2" },
createdAt: expect.any(Number), createdAt: expect.any(Number),
}, },
]); ]);
@@ -3,7 +3,6 @@ import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
createLauncherId,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual, workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity"; } from "@/utils/workspace-tab-identity";
@@ -13,7 +12,6 @@ export type WorkspaceTabTarget =
| { kind: "agent"; agentId: string } | { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string } | { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string } | { kind: "file"; path: string }
| { kind: "launcher"; launcherId: string }
| { kind: "setup"; workspaceId: string }; | { kind: "setup"; workspaceId: string };
export type WorkspaceTab = { export type WorkspaceTab = {
@@ -84,7 +82,6 @@ type WorkspaceTabsState = {
workspaceId: string; workspaceId: string;
target: WorkspaceTabTarget; target: WorkspaceTabTarget;
}) => string | null; }) => string | null;
openLauncherTab: (input: { serverId: string; workspaceId: string }) => string | null;
openOrFocusTab: (input: { openOrFocusTab: (input: {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
@@ -171,13 +168,6 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
return resolvedTabId; return resolvedTabId;
}, },
openLauncherTab: ({ serverId, workspaceId }) => {
return get().openOrFocusTab({
serverId,
workspaceId,
target: { kind: "launcher", launcherId: createLauncherId() },
});
},
openOrFocusTab: ({ serverId, workspaceId, target }) => { openOrFocusTab: ({ serverId, workspaceId, target }) => {
const tabId = get().ensureTab({ serverId, workspaceId, target }); const tabId = get().ensureTab({ serverId, workspaceId, target });
if (!tabId) { if (!tabId) {
@@ -22,10 +22,6 @@ export function normalizeWorkspaceTabTarget(
const path = trimNonEmpty(value.path); const path = trimNonEmpty(value.path);
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null; 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") { if (value.kind === "setup") {
const workspaceId = trimNonEmpty(value.workspaceId); const workspaceId = trimNonEmpty(value.workspaceId);
return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null; return workspaceId ? { kind: "setup", workspaceId: workspaceId.replace(/\\/g, "/") } : null;
@@ -52,10 +48,6 @@ export function workspaceTabTargetsEqual(
if (left.kind === "file" && right.kind === "file") { if (left.kind === "file" && right.kind === "file") {
return left.path === right.path; 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") { if (left.kind === "setup" && right.kind === "setup") {
return left.workspaceId === right.workspaceId; return left.workspaceId === right.workspaceId;
} }
@@ -72,22 +64,12 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "terminal") { if (target.kind === "terminal") {
return `terminal_${target.terminalId}`; return `terminal_${target.terminalId}`;
} }
if (target.kind === "launcher") {
return `launcher_${target.launcherId}`;
}
if (target.kind === "setup") { if (target.kind === "setup") {
return `setup_${target.workspaceId}`; return `setup_${target.workspaceId}`;
} }
return `file_${target.path}`; 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 { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
return null; return null;
+7
View File
@@ -22,10 +22,17 @@ if [ -z "${PASEO_HOME}" ]; then
fi fi
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 "══════════════════════════════════════════════════════"
echo " Paseo Dev" echo " Paseo Dev"
echo "══════════════════════════════════════════════════════" echo "══════════════════════════════════════════════════════"
echo " Home: ${PASEO_HOME}" echo " Home: ${PASEO_HOME}"
echo " Models: ${PASEO_LOCAL_MODELS_DIR}"
echo "══════════════════════════════════════════════════════" echo "══════════════════════════════════════════════════════"
# Configure the daemon for the Portless app origin and let the app bootstrap # Configure the daemon for the Portless app origin and let the app bootstrap