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),
    }),
}));