refactor(app): remove launcher tabs from workspace flow
This commit is contained in:
@@ -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 });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user