File size: 4,709 Bytes
5f40163 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 | import { create } from "zustand";
import { setConversationState } from "#/utils/conversation-local-storage";
interface FilesTabState {
selectedPath: string | null;
// The conversation a selection belongs to. A file picked in one
// conversation must not leak into another (it usually doesn't exist in the
// other conversation's workspace, see issue #1350), so every selection is
// tagged with its conversation and the files tab ignores a path owned by a
// different conversation.
selectedConversationId: string | null;
/**
* Ordered list of files the user or agent has opened in the current
* conversation. The quick-row tab strip renders only these paths.
*/
openPaths: string[];
setSelectedPath: (
path: string | null,
conversationId?: string | null,
) => void;
/** Remove a path from the open-tab strip; selects a neighbor when needed. */
closeOpenPath: (path: string) => void;
/**
* Replace in-memory open-tab state for a conversation (used when mounting
* / switching conversations so a refresh can restore localStorage).
* Does not write back to localStorage.
*/
hydrateForConversation: (
conversationId: string,
openPaths: string[],
selectedPath: string | null,
) => void;
}
function withOpenedPath(
openPaths: string[],
path: string,
sameConversation: boolean,
): string[] {
if (!sameConversation) return [path];
if (openPaths.includes(path)) return openPaths;
return [...openPaths, path];
}
function selectNeighborAfterClose(
openPaths: string[],
closedPath: string,
selectedPath: string | null,
): string | null {
if (selectedPath !== closedPath) return selectedPath;
const closedIndex = openPaths.indexOf(closedPath);
const remaining = openPaths.filter((path) => path !== closedPath);
if (remaining.length === 0) return null;
// Prefer the tab that slides into the closed slot (right neighbor), else left.
return remaining[Math.min(closedIndex, remaining.length - 1)] ?? null;
}
function resolveSelectedPath(
openPaths: string[],
selectedPath: string | null,
): string | null {
if (selectedPath && openPaths.includes(selectedPath)) return selectedPath;
return null;
}
function persistOpenState(
conversationId: string | null | undefined,
openPaths: string[],
selectedPath: string | null,
) {
if (!conversationId) return;
setConversationState(conversationId, {
filesTabOpenPaths: openPaths,
filesTabSelectedPath: selectedPath,
});
}
// Hoisted out of files-tab.tsx local state so non-React callers (e.g. the
// canvas_ui tool dispatcher in the WebSocket context) can drive selection.
export const useFilesTabStore = create<FilesTabState>((set) => ({
selectedPath: null,
selectedConversationId: null,
openPaths: [],
setSelectedPath: (selectedPath, conversationId = null) =>
set((state) => {
if (selectedPath === null) {
const switchedConversation =
conversationId !== state.selectedConversationId;
const next = {
selectedPath: null as string | null,
selectedConversationId: conversationId,
// Drop open tabs when the active conversation changes so paths
// from conversation A never appear as tabs in conversation B.
// Callers that switch conversations should prefer
// `hydrateForConversation` so persisted tabs can be restored.
openPaths: switchedConversation ? [] : state.openPaths,
};
persistOpenState(conversationId, next.openPaths, next.selectedPath);
return next;
}
const sameConversation = state.selectedConversationId === conversationId;
const next = {
selectedPath,
selectedConversationId: conversationId,
openPaths: withOpenedPath(
state.openPaths,
selectedPath,
sameConversation,
),
};
persistOpenState(conversationId, next.openPaths, next.selectedPath);
return next;
}),
closeOpenPath: (path) =>
set((state) => {
if (!state.openPaths.includes(path)) return state;
const selectedPath = selectNeighborAfterClose(
state.openPaths,
path,
state.selectedPath,
);
const next = {
openPaths: state.openPaths.filter((openPath) => openPath !== path),
selectedPath,
};
persistOpenState(
state.selectedConversationId,
next.openPaths,
next.selectedPath,
);
return next;
}),
hydrateForConversation: (conversationId, openPaths, selectedPath) =>
set({
selectedConversationId: conversationId,
openPaths,
selectedPath: resolveSelectedPath(openPaths, selectedPath),
}),
}));
|