返回 DeepSeek-Reasonix
ProjectTree.tsx
根目录 / desktop / frontend / src / components / ProjectTree.tsx
1 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2 import { captureListReadAnchor, restoreListReadAnchor, type ListReadAnchor } from "../lib/listReadAnchor";
3 import { ProjectTreeSessionBadges } from "./ProjectTreeSessionBadges";
4 import { sessionLifecycleFences } from "../lib/sessionLifecycleFences";
5 import { projectSessionIdentity, projectSessionRowKey } from "../lib/projectSessionIdentity";
6 import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
7 import { Archive, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, Minimize2, Maximize2, GitBranch, Sparkles, Cloud } from "lucide-react";
8 import { asArray } from "../lib/array";
9 import { defaultWorkspaceTitle } from "../lib/sessionTitles";
10 import { useToast } from "../lib/toast";
11 import { app } from "../lib/bridge";
12 import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
13 import { releaseReadSnapshot } from "../lib/readSnapshot";
14 import { sessionCatalogNotice } from "../lib/sessionCatalogPresentation";
15 import { sessionTitleErrorKey, sessionTitleTarget } from "../lib/sessionTitleOperation";
16 import { useSessionTitleOperation } from "../lib/useSessionTitleOperation";
17 import { isRuntimeSessionNode, isTopicNode, loadWorkbenchSortMode, mergeIncompleteProjectTopicPage, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeWithoutSession, projectTreeWithoutTopic, projectTreeWithSessionTitle, projectTreeWithTopicTitle, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_SORT_KEY, type ProjectTreePendingTopicOpen, type WorkbenchSortMode } from "../lib/projectTreeTopic";
18 export * from "../lib/projectTreeTopic";
19 import { arrangeWorkbenchTree, splitPinnedProjectTree, type PinnedTreeSections } from "../lib/projectTreePresentation";
20 export * from "../lib/projectTreePresentation";
21 import type { ProjectNode, SessionCatalogStatus } from "../lib/types";
22 import { useT, type Translator } from "../lib/i18n";
23 import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
24 import { projectTreeSessionArchiveTargetKey, projectTreeTopicArchiveTargetKey, projectTreeWithoutTopics, reloadProjectTreeTopics, useProjectTreeArchiveController, type ProjectTreeRefresh, type ProjectTreeRefreshOptions } from "../lib/projectTreeArchive";
25 import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
26 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
27 import { Tooltip } from "./Tooltip";
28 import { WorktreeBadge } from "./WorktreeBadge";
29 import { useProjectCreation } from "./useProjectCreation";
30 import { useProjectTreeRuntimeProjection } from "../lib/useProjectTreeRuntimeProjection";
31 import { useProjectTreeFrontendDiagnostics, type ProjectTreeDiagnosticSnapshot } from "../lib/useProjectTreeFrontendDiagnostics";
32 import { summarizeProjectTreeSessions } from "../lib/projectTreeDiagnostics";
33 import { GLOBAL_PROJECT_ORDER_KEY, ProjectTreeFolderActivity, ProjectTreeGroupRows, applyProjectOrder, projectTreeGroupContainsNode, projectTreeOrganizationKey, projectTreeProjectRoots, reorderedProjectRoots, useProjectTreeOrganization, type ProjectDropPosition } from "./ProjectTreeOrganization";
34 import { ProjectTreeSessionArchiveMenu } from "./ProjectTreeSessionArchiveMenu";
35 import { ProjectTreeHeaderAddControl, ProjectTreeRemoteAction, projectTreeHeaderAddItems } from "./ProjectTreeAddControls";
36 import { activeRemoteProjectAncestorKeys, buildRemoteProjectMenuItems, useRemoteRuntimeTree, openRemoteSessionNode, remoteProjectKey, remoteServeBadgeState, renameRemoteProjectTitle, RemoteProjectEmptyState, remoteSessionActionIdentity, remoteSessionArchiveBlocked, useRemoteProjectGroups, useRemoteSessionActions } from "./ProjectTreeRemoteGroups";
37 import type { ProjectTreeProps } from "./ProjectTreeProps";
38 import { PROJECT_TREE_SEARCH_PAGE, PROJECT_TREE_WINDOW_INITIAL, PROJECT_TREE_WINDOW_STEP, forgetProjectTreeWindowLimits, loadProjectTreePageWindow, projectTreeListKey, projectTreeListNeedsInitialization, projectTreeListShowsLoading, projectTreeProjectsNeedingInitialLoad, projectTreeWindowRows, reloadProjectTreeTopicLists, rememberProjectTreeWindowLimit, type ProjectTreeListPageState } from "../lib/projectTreeWindow";
39 import { useProjectTreeReadActivity } from "./useProjectTreeReadActivity";
40 import { useProjectTreeListRuntime } from "../lib/useProjectTreeListRuntime";
41 import { activeSessionAncestorKeys, collapsibleProjectTreeFolderKeys, defaultExpandedProjectTreeKeys, projectTreeNodeKey as projectNodeKey } from "../lib/projectTreeExpansion";
42 import { createProjectTreeRequestDiagnostic } from "../lib/projectTreeRequestDiagnostics";
43 export { activeSessionAncestorKeys, defaultExpandedProjectTreeKeys } from "../lib/projectTreeExpansion";
44
45 type WorkbenchHeaderMenu = "more" | "add" | null;
46
47 type CollapseSnapshot = {
48 expanded: Set<string>;
49 manuallyCollapsed: Set<string>;
50 };
51
52 // Global rows use the same project tree recipe; the fallback supplies their non-workspace accent.
53 function projectAccentStyle(color?: string, fallbackValue?: string): CSSProperties | undefined {
54 const value = projectColorValue(color) || fallbackValue;
55 if (!value) return undefined;
56 return { "--project-accent": value } as CSSProperties;
57 }
58
59 function colorMenuLabel(label: string, color?: string, active = false) {
60 const value = projectColorValue(color);
61 return (
62 <span className="project-tree__color-option">
63 <span
64 className="project-tree__color-swatch"
65 style={value ? ({ "--project-accent": value } as CSSProperties) : undefined}
66 aria-hidden="true"
67 />
68 <span>{label}</span>
69 {active && <Check className="project-tree__color-check" size={12} />}
70 </span>
71 );
72 }
73
74 function menuLabelWithCheck(label: string, checked: boolean) {
75 return (
76 <span className="context-menu__label-with-check">
77 <span className="context-menu__label-text">{label}</span>
78 {checked && <Check className="context-menu__check" size={13} aria-hidden="true" />}
79 </span>
80 );
81 }
82
83 function revealLabelKey(platform: string): "projectTree.revealInFinder" | "projectTree.revealInExplorer" | "projectTree.revealInFileManager" {
84 if (platform === "darwin") return "projectTree.revealInFinder";
85 if (platform === "windows") return "projectTree.revealInExplorer";
86 return "projectTree.revealInFileManager";
87 }
88
89 function projectColorLabel(t: Translator, color?: string): string {
90 switch (color) {
91 case "red": return t("projectTree.colorRed");
92 case "orange": return t("projectTree.colorOrange");
93 case "amber": return t("projectTree.colorAmber");
94 case "green": return t("projectTree.colorGreen");
95 case "teal": return t("projectTree.colorTeal");
96 case "blue": return t("projectTree.colorBlue");
97 case "purple": return t("projectTree.colorPurple");
98 case "pink": return t("projectTree.colorPink");
99 default: return t("projectTree.colorDefault");
100 }
101 }
102
103 export function ProjectTree({
104 activeScope,
105 activeWorkspaceRoot,
106 activeTopicId,
107 activeSessionPath,
108 activeRemote,
109 imTopicSources = {},
110 variant = "workbench",
111 onOpenTopic,
112 onAddProject,
113 onCreateTopic,
114 onCreateIsolatedWorktree,
115 onRenameTopic,
116 onTopicsChanged,
117 refreshSignal,
118 searchExpanded = true,
119 searchFocusSignal = 0,
120 showShortcutBadges = false,
121 shortcutPlatform,
122 onVisibleTopicsChange,
123 }: ProjectTreeProps) {
124 const t = useT();
125 const { showToast } = useToast();
126 const projectTreeRef = useRef<HTMLDivElement>(null);
127 const readAnchorRef = useRef<ListReadAnchor | undefined>(undefined);
128 const compactTopics = variant === "workbench";
129 const creationTopics = variant === "creation";
130 const [tree, setTree] = useState<ProjectNode[]>([]);
131 const [shellStage, setShellStage] = useState<"loading" | "slow" | "ready">("loading");
132 const treeRef = useRef<ProjectNode[]>([]);
133 const latestRevisionRef = useRef(0);
134 const shellRequestRef = useRef(0);
135 const shellGenerationRef = useRef<number | undefined>(undefined);
136 const [organizationRevision, setOrganizationRevision] = useState(0);
137 const {
138 topicRevisionRef, topicCompletePageRef, topicPageState, setTopicPageState, topicPageStateRef,
139 updateTopicPageState, topicWindowLimits, setTopicWindowLimits, topicWindowLimitsRef,
140 resetTopicWindowLimits, topicLoadSeqRef, topicLoadPendingRef, topicRequestLimiterRef,
141 topicLoadErrorRef, invalidateProjectTopicLists,
142 } = useProjectTreeListRuntime();
143 const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
144 state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
145 repairActive: 0, repairDeferred: 0, repairBlocked: 0,
146 });
147 const catalogStatusGenerationRef = useRef(0), rebuildingCatalogRef = useRef(false), catalogRebuildFailedRef = useRef(false);
148 const [expanded, setExpanded] = useState<Set<string>>(new Set());
149 const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
150 const [creatingProject, setCreatingProject] = useState<string | null>(null);
151 const [query, setQuery] = useState("");
152 const [editingTopic, setEditingTopic] = useState<string | null>(null);
153 const [editingSession, setEditingSession] = useState<{ key: string; path: string; topicId: string } | null>(null);
154 const [topicDraft, setTopicDraft] = useState("");
155 const [menuNodeKey, setMenuNodeKey] = useState<string | null>(null);
156 const [menuProject, setMenuProject] = useState<{ key: string; root: string; path: string; scope: "global" | "project"; label: string } | null>(null);
157 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
158 const [editingProject, setEditingProject] = useState<{ key: string; root: string } | null>(null);
159 const [projectDraft, setProjectDraft] = useState("");
160 const [isolatingProject, setIsolatingProject] = useState<string | null>(null);
161 const [worktreeAvailability, setWorktreeAvailability] = useState<Record<string, { available: boolean; reason?: string }>>({});
162 const [confirmArchiveTarget, setConfirmArchiveTarget] = useState<string | null>(null);
163 const [confirmRemoveProject, setConfirmRemoveProject] = useState<string | null>(null);
164 const [dragProjectRoot, setDragProjectRoot] = useState<string | null>(null);
165 const [dropProject, setDropProject] = useState<{ root: string; position: ProjectDropPosition } | null>(null);
166 const [collapseSnapshot, setCollapseSnapshot] = useState<CollapseSnapshot | null>(null);
167 const [platform, setPlatform] = useState("");
168 const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
169 const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
170 const workbenchSortModeRef = useRef(workbenchSortMode);
171 const searchInputRef = useRef<HTMLInputElement>(null);
172 const topicIndexRef = useRef(0);
173 const visibleTopicsCollectorRef = useRef<TopicShortcutEntry[]>([]);
174 const creatingRef = useRef(false);
175 const closeMenu = useCallback(() => {
176 setMenuNodeKey(null);
177 setMenuProject(null);
178 setMenuPoint(null);
179 setConfirmArchiveTarget(null);
180 setConfirmRemoveProject(null);
181 setWorkbenchHeaderMenu(null);
182 }, []);
183 const topicRequestContextRef = useRef({ query: query.trim(), sortMode: creationTopics ? "updated" : workbenchSortMode });
184 topicRequestContextRef.current = { query: query.trim(), sortMode: creationTopics ? "updated" : workbenchSortMode };
185 const activeSummaryRequestRef = useRef("");
186 const refreshRef = useRef<ProjectTreeRefresh>(async () => {});
187 const { trashingTopics, trashingSessions, currentArchiveTombstones, trashTopic, trashSession, inspectTopicRemoval, topicRemovalInspections } = useProjectTreeArchiveController({
188 treeRef, invalidateProjectTopicLists, refreshRef,
189 optimisticallyRemoveTopic: (topicId) => setTree((current) => projectTreeWithoutTopic(current, topicId)),
190 optimisticallyRemoveSession: (node) => setTree((current) => projectTreeWithoutSession(current, node)),
191 closeMenu, onTopicsChanged, showToast,
192 sessionErrorMessage: (error) => t(sessionTitleErrorKey(error)),
193 });
194 const applyRuntimeProjection = useProjectTreeRuntimeProjection(setTree, currentArchiveTombstones);
195 const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
196 useEffect(() => {
197 const invalidatePendingOpen = (event: Event) => {
198 const pending = clickTimerRef.current;
199 if (!pending) return;
200 const target = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-topic-open-key]") : null;
201 if (event.type === "click" && target?.dataset.topicOpenKey === pending.rowKey) return;
202 const keyEvent = event as globalThis.KeyboardEvent;
203 if (event.type === "keydown" && !keyEvent.metaKey && !keyEvent.ctrlKey && keyEvent.key !== "Escape") return;
204 clearTimeout(pending.timer);
205 clickTimerRef.current = null;
206 };
207 document.addEventListener("click", invalidatePendingOpen, true);
208 document.addEventListener("keydown", invalidatePendingOpen, true);
209 return () => {
210 document.removeEventListener("click", invalidatePendingOpen, true);
211 document.removeEventListener("keydown", invalidatePendingOpen, true);
212 };
213 }, []);
214 useEffect(() => {
215 return () => {
216 if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current.timer);
217 };
218 }, []);
219 const manuallyCollapsedRef = useRef(manuallyCollapsed);
220 const updateManuallyCollapsed = useCallback((updater: (prev: Set<string>) => Set<string>) => {
221 setManuallyCollapsed((prev) => {
222 const next = updater(prev);
223 manuallyCollapsedRef.current = next;
224 return next;
225 });
226 }, []);
227
228 const loadProjectTopicsRef = useRef<(project: ProjectNode, append?: boolean, groupID?: string, background?: boolean) => Promise<void>>(async () => {});
229 const topicRefreshPendingRef = useRef(new Map<string, () => void>());
230 const topicLastStartRef = useRef<Record<string, number>>({});
231 const topicRefreshTimersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>());
232 useEffect(() => () => {
233 for (const timer of topicRefreshTimersRef.current.values()) clearTimeout(timer);
234 topicRefreshPendingRef.current.clear();
235 for (const state of Object.values(topicPageStateRef.current)) releaseReadSnapshot(state.snapshotId);
236 for (const key of Object.keys(topicLoadSeqRef.current)) topicLoadSeqRef.current[key]++;
237 }, [topicLoadSeqRef, topicPageStateRef]);
238
239 const loadProjectTopics = useCallback(async (project: ProjectNode, append = false, groupID = "", background = false) => {
240 if ((project.kind !== "project" && project.kind !== "global_folder") || project.remote) return;
241 const key = project.key;
242 const normalizedQuery = query.trim();
243 const listKey = projectTreeListKey(key, groupID, normalizedQuery);
244 if (topicLoadPendingRef.current[listKey] !== undefined) {
245 if (!append) topicRefreshPendingRef.current.set(listKey, () => { void loadProjectTopicsRef.current(project, false, groupID, true); });
246 return;
247 }
248 if (background && !append && Date.now() - (topicLastStartRef.current[listKey] ?? 0) < 500) {
249 if (!topicRefreshTimersRef.current.has(listKey)) {
250 topicRefreshTimersRef.current.set(listKey, setTimeout(() => {
251 topicRefreshTimersRef.current.delete(listKey);
252 void loadProjectTopicsRef.current(project, false, groupID, true);
253 }, 500 - (Date.now() - topicLastStartRef.current[listKey])));
254 }
255 return;
256 }
257 topicLastStartRef.current[listKey] = Date.now();
258 const pageState = topicPageStateRef.current[listKey];
259 if (pageState?.loading) return;
260 const cursor = append ? pageState?.nextCursor ?? "" : "";
261 if (append && !cursor) return;
262 const sortMode = creationTopics ? "updated" : workbenchSortModeRef.current;
263 const windowKey = projectTreeListKey(key, groupID);
264 const limit = normalizedQuery
265 ? PROJECT_TREE_SEARCH_PAGE
266 : append
267 ? PROJECT_TREE_WINDOW_STEP
268 : topicWindowLimitsRef.current[windowKey] ?? PROJECT_TREE_WINDOW_INITIAL;
269 const excludePinned = !creationTopics && !project.pinned;
270 // Completeness belongs to the logical list, not to the size of the page
271 // used to paint it. This lets an incomplete refresh retain a previously
272 // complete 5/15/25-row screen while the catalog repairs itself.
273 const requestSignature = [
274 normalizedQuery,
275 groupID,
276 sortMode,
277 excludePinned ? "exclude-pinned" : "include-pinned",
278 ].join("\u001f");
279 // Last-query-wins: stale completions cannot overwrite a newer first page.
280 const seq = (topicLoadSeqRef.current[listKey] ?? 0) + 1;
281 topicLoadSeqRef.current[listKey] = seq;
282 topicLoadPendingRef.current[listKey] = seq;
283 updateTopicPageState(listKey, { ...pageState, loading: true,
284 refreshing: background && !append && pageState?.itemKeys !== undefined, error: undefined });
285 const emitRequest = createProjectTreeRequestDiagnostic({ projectKind: project.kind, creationTopics, sequence: seq, stats: () => topicRequestLimiterRef.current.stats() });
286 try {
287 const page = await topicRequestLimiterRef.current.run(() => {
288 emitRequest("started");
289 const context = topicRequestContextRef.current;
290 if (topicLoadSeqRef.current[listKey] !== seq || context.query !== normalizedQuery || context.sortMode !== sortMode) return (emitRequest("discarded", { status: "stale" }), Promise.resolve(null));
291 return loadProjectTreePageWindow(cursor, limit, (pageCursor, pageLimit) => app.ListProjectTopics({
292 scope: project.kind === "global_folder" ? "global" : "project",
293 workspaceRoot: project.kind === "global_folder" ? "" : project.root ?? "",
294 cursor: pageCursor,
295 limit: pageLimit,
296 query: normalizedQuery,
297 sortMode,
298 groupFilter: normalizedQuery ? "all" : groupID ? "group" : "ungrouped",
299 groupId: groupID || undefined,
300 // Individually pinned topics live in the standalone pinned section
301 // for ordinary projects. A pinned project is itself that section's
302 // folder, so its children must remain available inside it.
303 excludePinned,
304 }), append ? Math.max(limit, (pageState?.itemKeys?.length ?? 0) + limit) : limit);
305 });
306 if (!page) return;
307 if (topicLoadSeqRef.current[listKey] !== seq) { if (!append || page.replacedSnapshot) releaseReadSnapshot(page.snapshotId); emitRequest("discarded", { status: "stale", itemCount: page.items.length }); return; }
308 const currentContext = topicRequestContextRef.current;
309 if (currentContext.query !== normalizedQuery || currentContext.sortMode !== sortMode) { if (!append || page.replacedSnapshot) releaseReadSnapshot(page.snapshotId); emitRequest("discarded", { status: "stale", itemCount: page.items.length }); return; }
310 const appendPage = append && !page.replacedSnapshot;
311 if (appendPage && pageState?.snapshotId && page.snapshotId !== pageState.snapshotId) throw new Error("Mixed read snapshots in one list");
312 delete topicLoadErrorRef.current[listKey];
313 if (!projectTreeTopicPageIsFresh(topicRevisionRef.current, listKey, page.revision)) {
314 if (!appendPage) releaseReadSnapshot(page.snapshotId);
315 updateTopicPageState(listKey, { ...topicPageStateRef.current[listKey], loading: false });
316 emitRequest("discarded", { status: "stale", itemCount: page.items.length });
317 return;
318 }
319 topicRevisionRef.current[listKey] = Math.max(topicRevisionRef.current[listKey] ?? 0, page.revision);
320 sessionLifecycleFences.observeDirectory(asArray(page.items));
321 const items = projectTreeWithoutTopics(asArray(page.items), currentArchiveTombstones());
322 const completeBaseline = topicCompletePageRef.current[listKey];
323 const preserveCompletePage = page.complete === false && completeBaseline?.signature === requestSignature;
324 const incomingKeys = items.map((item) => item.key);
325 const previousKeys = topicPageStateRef.current[listKey]?.itemKeys ?? [];
326 const itemKeys = appendPage || preserveCompletePage
327 ? [...new Set([...previousKeys, ...incomingKeys])]
328 : incomingKeys;
329 if (page.complete !== false) {
330 topicCompletePageRef.current[listKey] = { signature: requestSignature, revision: page.revision };
331 }
332 if (!appendPage && pageState?.itemKeys !== undefined && !readAnchorRef.current) readAnchorRef.current = captureListReadAnchor(projectTreeRef.current);
333 setTree((current) => applyRuntimeProjection(current.map((node) => {
334 if (node.key !== key) return node;
335 const previous = new Set(previousKeys);
336 const otherLists = new Set(Object.entries(topicPageStateRef.current)
337 .filter(([other]) => other !== listKey && other.startsWith(`${key}\u001f`))
338 .flatMap(([, state]) => state.itemKeys ?? []));
339 const retained = appendPage ? asArray(node.children) : asArray(node.children)
340 .filter((child) => child.pinned || !previous.has(child.key) || otherLists.has(child.key));
341 const children = preserveCompletePage
342 ? mergeIncompleteProjectTopicPage(asArray(node.children), items)
343 : mergeProjectTopicPage(retained, items, true);
344 return children === node.children ? node : { ...node, children };
345 })));
346 updateTopicPageState(listKey, preserveCompletePage
347 ? { ...topicPageStateRef.current[listKey], itemKeys, loading: false, initialized: true }
348 : { itemKeys, nextCursor: page.nextCursor, snapshotId: page.snapshotId, loading: false, initialized: true });
349 if (preserveCompletePage && !appendPage) releaseReadSnapshot(page.snapshotId);
350 else if (!appendPage && pageState?.snapshotId !== page.snapshotId) releaseReadSnapshot(pageState?.snapshotId);
351 emitRequest("completed", { status: "ok", itemCount: items.length });
352 } catch (error) {
353 if (topicLoadSeqRef.current[listKey] !== seq) return void emitRequest("discarded", { status: "stale" });
354 const message = error instanceof Error ? error.message : String(error);
355 updateTopicPageState(listKey, { ...topicPageStateRef.current[listKey], loading: false, initialized: true, error: message });
356 if (topicLoadErrorRef.current[listKey] !== message) {
357 topicLoadErrorRef.current[listKey] = message;
358 showToast(message, "error", { durationMs: 6000 });
359 }
360 emitRequest("failed", { status: "error" });
361 } finally {
362 if (topicLoadPendingRef.current[listKey] === seq) delete topicLoadPendingRef.current[listKey];
363 if (topicLoadSeqRef.current[listKey] === seq) {
364 const pending = topicRefreshPendingRef.current.get(listKey);
365 topicRefreshPendingRef.current.delete(listKey);
366 pending?.();
367 }
368 }
369 }, [applyRuntimeProjection, creationTopics, currentArchiveTombstones, query, showToast, updateTopicPageState]);
370 loadProjectTopicsRef.current = loadProjectTopics;
371 useLayoutEffect(() => { restoreListReadAnchor(projectTreeRef.current, readAnchorRef.current); readAnchorRef.current = undefined; }, [tree]);
372
373 const topicListState = useCallback((project: ProjectNode, groupID = "") => (
374 topicPageState[projectTreeListKey(project.key, groupID, query)]
375 ), [query, topicPageState]);
376 const topicListLimit = useCallback((project: ProjectNode, groupID = "") => (
377 topicWindowLimits[projectTreeListKey(project.key, groupID)] ?? PROJECT_TREE_WINDOW_INITIAL
378 ), [topicWindowLimits]);
379 const ensureTopicList = useCallback((project: ProjectNode, groupID = "") => {
380 const state = topicPageStateRef.current[projectTreeListKey(project.key, groupID, query)];
381 if (!projectTreeListNeedsInitialization(state)) return Promise.resolve();
382 return loadProjectTopics(project, false, groupID);
383 }, [loadProjectTopics, query]);
384 const ensureTopicListRef = useRef(ensureTopicList);
385 ensureTopicListRef.current = ensureTopicList;
386 const expandTopicList = useCallback((project: ProjectNode, groupID = "", loadedCount = 0) => {
387 const key = projectTreeListKey(project.key, groupID);
388 const requestKey = projectTreeListKey(project.key, groupID, query);
389 if (!project.remote && (topicLoadPendingRef.current[requestKey] !== undefined || topicPageStateRef.current[requestKey]?.loading)) return;
390 const nextLimit = (topicWindowLimitsRef.current[key] ?? PROJECT_TREE_WINDOW_INITIAL) + PROJECT_TREE_WINDOW_STEP;
391 setTopicWindowLimits((current) => {
392 const next = { ...current, [key]: nextLimit };
393 rememberProjectTreeWindowLimit(key, next[key]);
394 topicWindowLimitsRef.current = next;
395 return next;
396 });
397 if (!project.remote && loadedCount < nextLimit && topicPageStateRef.current[requestKey]?.nextCursor) {
398 void loadProjectTopics(project, true, groupID);
399 }
400 }, [loadProjectTopics, query]);
401
402 const retryTopicList = useCallback((project: ProjectNode, groupID = "") => {
403 const state = topicPageStateRef.current[projectTreeListKey(project.key, groupID, query)];
404 void loadProjectTopics(project, Boolean(state?.nextCursor), groupID);
405 }, [loadProjectTopics, query]);
406 const forgetTopicList = useCallback((project: ProjectNode, groupID: string) => {
407 const listKey = projectTreeListKey(project.key, groupID);
408 topicLoadSeqRef.current[listKey] = (topicLoadSeqRef.current[listKey] ?? 0) + 1;
409 delete topicLoadPendingRef.current[listKey];
410 delete topicRevisionRef.current[listKey];
411 delete topicCompletePageRef.current[listKey];
412 delete topicLoadErrorRef.current[listKey];
413 const nextPages = { ...topicPageStateRef.current };
414 releaseReadSnapshot(nextPages[listKey]?.snapshotId);
415 delete nextPages[listKey];
416 topicPageStateRef.current = nextPages;
417 setTopicPageState(nextPages);
418 const nextLimits = { ...topicWindowLimitsRef.current };
419 delete nextLimits[listKey];
420 topicWindowLimitsRef.current = nextLimits;
421 setTopicWindowLimits(nextLimits);
422 rememberProjectTreeWindowLimit(listKey, PROJECT_TREE_WINDOW_INITIAL);
423 }, []);
424 const reloadProjectTopicLists = useCallback((project: ProjectNode) => {
425 return reloadProjectTreeTopicLists(project,
426 topicRequestContextRef.current.query, topicPageStateRef.current,
427 (target, groupID) => loadProjectTopicsRef.current(target, false, groupID, true));
428 }, []);
429
430 const changeQuery = useCallback((value: string) => {
431 if (value.trim() !== topicRequestContextRef.current.query) {
432 // Retain painted rows, but retire requests/cursors from the old search
433 // before any completion can run between this event and the next render.
434 topicRequestContextRef.current = { ...topicRequestContextRef.current, query: value.trim() };
435 for (const project of treeRef.current) invalidateProjectTopicLists(project.key);
436 }
437 setQuery(value);
438 }, [invalidateProjectTopicLists]);
439
440 const selectWorkbenchSortMode = useCallback((sortMode: WorkbenchSortMode) => {
441 if (workbenchSortModeRef.current === sortMode) {
442 closeMenu();
443 return;
444 }
445 // Invalidate before scheduling React state so an already-resolved older
446 // request cannot write back during the render/effect gap. Its pagination
447 // cursor belongs to the old order and must not be reused by the new query.
448 workbenchSortModeRef.current = sortMode;
449 topicRequestContextRef.current = { query: query.trim(), sortMode };
450 for (const project of treeRef.current) invalidateProjectTopicLists(project.key);
451 setWorkbenchSortMode(sortMode);
452 closeMenu();
453
454 const filtering = query.trim() !== "";
455 for (const project of treeRef.current) {
456 const key = projectNodeKey(project, 0);
457 if (filtering || expanded.has(key)) void ensureTopicList(project);
458 }
459 }, [closeMenu, ensureTopicList, expanded, invalidateProjectTopicLists, query]);
460 // Snapshot carries project shells plus lightweight pinned topic shells.
461 // Preserve already loaded pages by project key while reconciling pins, so a
462 // metadata refresh does not collapse or blank the sidebar.
463 const refresh = useCallback(async (options?: ProjectTreeRefreshOptions, throwOnSnapshotError = false) => {
464 const request = ++shellRequestRef.current;
465 const reloadRequestedProjects = (projects: ProjectNode[]) => reloadProjectTreeTopics(projects, options, reloadProjectTopicLists), catalogStatusGeneration = catalogStatusGenerationRef.current;
466 try {
467 const snapshot = await app.GetProjectTreeSnapshot();
468 if (request !== shellRequestRef.current) return;
469 const rev = snapshot.revision ?? 0, empty = treeRef.current.length === 0;
470 // Catalog revisions describe topic indexing, not workspace membership.
471 // Compare the authoritative membership generation independently; an old
472 // registry cannot become fresh merely because an index scan advanced.
473 const generation = snapshot.workspaceGeneration ?? undefined;
474 const fresh = generation === undefined
475 ? projectTreeShouldApplyShellSnapshot({ currentRevision: latestRevisionRef.current, incomingRevision: rev, treeEmpty: empty })
476 : shellGenerationRef.current === undefined || generation >= shellGenerationRef.current;
477 if (!fresh) {
478 await reloadRequestedProjects(treeRef.current);
479 return;
480 }
481 if (generation !== undefined) shellGenerationRef.current = generation;
482 if (projectTreeRevisionIsFresh(latestRevisionRef.current, rev)) latestRevisionRef.current = Math.max(latestRevisionRef.current, rev);
483 const projects = asArray(snapshot.projects);
484 if (!catalogRebuildFailedRef.current && catalogStatusGeneration === catalogStatusGenerationRef.current) setCatalogStatus(snapshot.catalog);
485 setTree((current) => applyRuntimeProjection(projects.map((project) => {
486 const previous = current.find((node) => node.key === project.key);
487 // Topic pages reload asynchronously. Keep the last painted children
488 // until their replacement arrives so a mutation cannot blank every
489 // expanded folder for the duration of a catalog scan.
490 return { ...project, children: projectTreeShellChildren(previous?.children, project.children) };
491 })));
492 await reloadRequestedProjects(projects);
493 } catch (err) {
494 // A failed shell read can still reload topics from resident folders.
495 if (request === shellRequestRef.current) await reloadRequestedProjects(treeRef.current);
496 if (throwOnSnapshotError) throw err;
497 } finally {
498 if (request === shellRequestRef.current) setShellStage("ready");
499 }
500 }, [applyRuntimeProjection, reloadProjectTopicLists]);
501 refreshRef.current = refresh;
502 const { openRemoteProject, openRemoteWindow, remoteSessions, setRemoteSessions, remoteServers, remoteGroupBusy, remoteGroupError, ensureRemoteGroupSessions, refreshRemoteSessions } = useRemoteProjectGroups(tree, showToast, expanded, query);
503 const treeWithRemoteSessions = useRemoteRuntimeTree(tree, remoteSessions, t);
504 const remoteSessionActions = useRemoteSessionActions(remoteSessions, refreshRemoteSessions, (error) => showToast(error instanceof Error ? error.message : String(error), "error"));
505 const { addingProject, handleAddProject, openBlankProjectFlow, blankProjectFlow, openRemoteConnectFlow, remoteConnectFlow } = useProjectCreation({
506 onAddProject,
507 onRefresh: () => refresh(undefined, true),
508 showToast,
509 });
510
511 const rebuildSessionCatalog = useCallback(async () => {
512 if (rebuildingCatalogRef.current || catalogStatus.canRebuild !== true) return;
513 rebuildingCatalogRef.current = true; catalogRebuildFailedRef.current = false; catalogStatusGenerationRef.current += 1;
514 setCatalogStatus({ ...catalogStatus, state: "rebuilding", canRebuild: false });
515 try {
516 await app.RebuildSessionCatalog(); catalogStatusGenerationRef.current += 1;
517 await refresh();
518 } catch {
519 catalogRebuildFailedRef.current = true; catalogStatusGenerationRef.current += 1; setCatalogStatus(catalogStatus);
520 } finally {
521 rebuildingCatalogRef.current = false;
522 }
523 }, [catalogStatus, refresh]);
524
525 useEffect(() => {
526 treeRef.current = tree;
527 }, [tree]);
528
529 useEffect(() => {
530 if (activeRemote || !activeTopicId || (activeScope !== "global" && activeScope !== "project")) return;
531 const workspaceRoot = activeScope === "global" ? "" : activeWorkspaceRoot ?? "";
532 const project = tree.find((node) => activeScope === "global"
533 ? node.kind === "global_folder"
534 : node.kind === "project" && node.root === workspaceRoot);
535 if (!project) return;
536 if (asArray(project.children).some((node) => node.topicId === activeTopicId)) return;
537 const requestKey = [activeScope, workspaceRoot, activeTopicId].join("\u001f");
538 if (activeSummaryRequestRef.current === requestKey) return;
539 activeSummaryRequestRef.current = requestKey;
540 void app.GetTopicSummary({ scope: activeScope, workspaceRoot, topicId: activeTopicId }).then((summary) => {
541 if (!summary?.topicId || activeSummaryRequestRef.current !== requestKey) return;
542 setTree((current) => current.map((node) => node.key === project.key
543 ? { ...node, children: mergeProjectTopicPage(asArray(node.children), [summary], true) }
544 : node));
545 }).catch(() => {}).finally(() => {
546 if (activeSummaryRequestRef.current === requestKey) activeSummaryRequestRef.current = "";
547 });
548 }, [activeRemote, activeScope, activeTopicId, activeWorkspaceRoot, tree]);
549
550 useEffect(() => {
551 manuallyCollapsedRef.current = manuallyCollapsed;
552 }, [manuallyCollapsed]);
553
554 const searchVisible = searchExpanded || query.trim().length > 0;
555
556 useEffect(() => {
557 if (!searchVisible || searchFocusSignal <= 0) return;
558 searchInputRef.current?.focus();
559 }, [searchFocusSignal, searchVisible]);
560
561 useEffect(() => {
562 void refresh();
563 return () => { shellRequestRef.current++; };
564 }, [refresh, refreshSignal]);
565
566 useEffect(() => {
567 if (shellStage !== "loading") return;
568 const timer = setTimeout(() => setShellStage("slow"), 250); return () => clearTimeout(timer);
569 }, [shellStage]);
570 useEffect(() => {
571 let timer: ReturnType<typeof setTimeout> | undefined;
572 let latest: Parameters<Parameters<typeof onProjectTreeChangedV2>[0]>[0] | undefined;
573 const apply = (event: NonNullable<typeof latest>) => {
574 // A stale or missed revision means the tree may have drifted from the
575 // catalog; refetch the full snapshot instead of dropping the event.
576 if (!projectTreeRevisionIsFresh(latestRevisionRef.current, event.revision)) {
577 void refresh();
578 }
579 latestRevisionRef.current = Math.max(latestRevisionRef.current, event.revision);
580 if (event.reason === "metadata") setOrganizationRevision((current) => Math.max(current, event.revision));
581 const catalogStatusGeneration = catalogStatusGenerationRef.current;
582 void app.GetSessionCatalogStatus().then((status) => { if (!catalogRebuildFailedRef.current && catalogStatusGeneration === catalogStatusGenerationRef.current) setCatalogStatus(status); }).catch(() => {});
583 if (treeRef.current.length === 0) { void refresh(); return; } // race: event before shell
584 const affected = asArray(event.roots);
585 for (const project of treeRef.current) {
586 const key = projectNodeKey(project, 0);
587 if (projectTreeEventAffectsFolder(project, affected)) {
588 if (topicRequestContextRef.current.query || expanded.has(key)) void reloadProjectTopicLists(project);
589 else invalidateProjectTopicLists(project.key);
590 }
591 }
592 };
593 const unsubscribe = onProjectTreeChangedV2((event) => {
594 latest = latest ? { ...event, roots: latest.roots.length && event.roots.length ? [...new Set([...latest.roots, ...event.roots])] : [], reason: latest.reason === "metadata" ? "metadata" : event.reason } : event;
595 if (timer === undefined) timer = setTimeout(() => { timer = undefined; const event = latest; latest = undefined; if (event) apply(event); }, 200);
596 });
597 return () => { unsubscribe(); if (timer !== undefined) clearTimeout(timer); };
598 }, [expanded, invalidateProjectTopicLists, refresh, reloadProjectTopicLists]);
599 // Debounce query reloads so typing does not stampede the catalog.
600 // Dependency is the project-shell signature, not tree: topic page loads
601 // rewrite children and would otherwise re-arm this effect in a loop.
602 const projectShellSignature = useMemo(() => projectTreeShellSignature(tree), [tree]);
603 useEffect(() => {
604 const projectKeys = new Set(tree.map((project) => project.key));
605 forgetProjectTreeWindowLimits(projectKeys);
606 const keep = (key: string) => {
607 const separator = key.indexOf("\u001f");
608 return projectKeys.has(separator >= 0 ? key.slice(0, separator) : key);
609 };
610 let changed = false;
611 const nextPages: Record<string, ProjectTreeListPageState> = {};
612 for (const [key, state] of Object.entries(topicPageStateRef.current)) {
613 if (keep(key)) nextPages[key] = state;
614 else { releaseReadSnapshot(state.snapshotId); changed = true; }
615 }
616 if (changed) {
617 topicPageStateRef.current = nextPages;
618 setTopicPageState(nextPages);
619 }
620 // Keep monotonically increasing request identities if a project is removed
621 // and re-added while an older RPC is still completing (A -> B -> A).
622 for (const key of Object.keys(topicLoadSeqRef.current)) if (!keep(key)) topicLoadSeqRef.current[key]++;
623 for (const [key, timer] of topicRefreshTimersRef.current) if (!keep(key)) { clearTimeout(timer); topicRefreshTimersRef.current.delete(key); }
624 for (const key of topicRefreshPendingRef.current.keys()) if (!keep(key)) topicRefreshPendingRef.current.delete(key);
625 for (const records of [topicLoadPendingRef.current, topicRevisionRef.current, topicCompletePageRef.current, topicLoadErrorRef.current]) {
626 for (const key of Object.keys(records)) if (!keep(key)) delete records[key];
627 }
628 }, [projectShellSignature, tree]);
629 // Only search is debounced. All first-page consumers share ensureTopicList;
630 // a delayed search must not reload a page already fetched by an event.
631 useEffect(() => {
632 if (!query.trim()) return;
633 const timer = setTimeout(() => {
634 for (const project of treeRef.current) {
635 void ensureTopicListRef.current(project);
636 }
637 }, 200);
638 return () => clearTimeout(timer);
639 }, [projectShellSignature, query]);
640
641 useEffect(() => {
642 if (query.trim()) return;
643 const projects = projectTreeProjectsNeedingInitialLoad(
644 treeRef.current,
645 expanded,
646 query,
647 topicPageStateRef.current,
648 (project) => projectNodeKey(project, 0),
649 );
650 for (const project of projects) void ensureTopicListRef.current(project);
651 }, [expanded, projectShellSignature, query]);
652
653 // Following the active topic is a view concern over the tree already held.
654 useEffect(() => {
655 const collapsed = manuallyCollapsedRef.current;
656 const keys = (activeRemote
657 ? activeRemoteProjectAncestorKeys(treeWithRemoteSessions, activeRemote, projectNodeKey)
658 : defaultExpandedProjectTreeKeys(treeWithRemoteSessions, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath))
659 .filter((key) => !collapsed.has(key));
660 // Returning prev unchanged keeps a switch that expands nothing new from
661 // re-rendering the tree at all.
662 setExpanded((prev) => (keys.every((key) => prev.has(key)) ? prev : new Set([...prev, ...keys])));
663 // Active remote groups get the same explicit cold start as a click.
664 for (const node of treeWithRemoteSessions) {
665 if (!node.remote) continue;
666 if (!keys.includes(projectNodeKey(node, 0))) continue;
667 if (!remoteSessions[remoteProjectKey(node.remote)]?.length) void ensureRemoteGroupSessions(node.remote.hostId, node.remote.workspace);
668 }
669 // eslint-disable-next-line react-hooks/exhaustive-deps
670 }, [tree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote]);
671
672 const { readActivity, readBaselineAt, markNodeRead } = useProjectTreeReadActivity(treeWithRemoteSessions);
673
674 useEffect(() => {
675 const markActive = (nodes: ProjectNode[]) => {
676 for (const node of nodes) {
677 if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote)) markNodeRead(node);
678 markActive(asArray(node.children));
679 }
680 };
681 markActive(treeWithRemoteSessions);
682 }, [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, treeWithRemoteSessions]);
683
684 useEffect(() => {
685 try {
686 localStorage.setItem(WORKBENCH_SORT_KEY, workbenchSortMode);
687 } catch {
688 /* ignore */
689 }
690 }, [workbenchSortMode]);
691
692 useEffect(() => {
693 let cancelled = false;
694 void app.Platform().then((value) => {
695 if (!cancelled) setPlatform(value);
696 }).catch(() => {});
697 return () => {
698 cancelled = true;
699 };
700 }, []);
701
702 const toggleExpand = (key: string, project?: ProjectNode) => {
703 const willCollapse = expanded.has(key);
704 if (willCollapse && project) resetTopicWindowLimits(project.key);
705 setExpanded((prev) => {
706 const next = new Set(prev);
707 if (next.has(key)) next.delete(key);
708 else next.add(key);
709 return next;
710 });
711 updateManuallyCollapsed((prev) => {
712 const next = new Set(prev);
713 if (willCollapse) next.add(key);
714 else next.delete(key);
715 return next;
716 });
717 };
718
719 const folderKeys = useMemo(() => collapsibleProjectTreeFolderKeys(tree), [tree]);
720 const searchActive = query.trim().length > 0;
721 const hasExpandedFolders = !searchActive && folderKeys.some((key) => expanded.has(key));
722 const canRestoreCollapsedView = collapseSnapshot !== null;
723 const canToggleCollapsedView = !searchActive && folderKeys.length > 0 && (hasExpandedFolders || canRestoreCollapsedView);
724 const collapseToggleLabel = t(canRestoreCollapsedView ? "projectTree.restoreCollapsedTooltip" : "projectTree.collapseAllTooltip");
725 const workbenchCollapseToggleLabel = t(canRestoreCollapsedView ? "projectTree.restoreCollapsedWorkbench" : "projectTree.collapseAllWorkbench");
726
727 const toggleCollapsedView = useCallback(() => {
728 if (searchActive || folderKeys.length === 0) return;
729 if (collapseSnapshot) {
730 const currentFolderKeys = new Set(folderKeys);
731 setExpanded(() => {
732 const next = new Set<string>();
733 for (const key of collapseSnapshot.expanded) {
734 if (currentFolderKeys.has(key)) next.add(key);
735 }
736 return next;
737 });
738 updateManuallyCollapsed(() => {
739 const next = new Set<string>();
740 for (const key of collapseSnapshot.manuallyCollapsed) {
741 if (currentFolderKeys.has(key)) next.add(key);
742 }
743 return next;
744 });
745 setCollapseSnapshot(null);
746 return;
747 }
748 if (!hasExpandedFolders) return;
749 resetTopicWindowLimits();
750 setCollapseSnapshot({
751 expanded: new Set(expanded),
752 manuallyCollapsed: new Set(manuallyCollapsed),
753 });
754 setExpanded((prev) => {
755 let changed = false;
756 const next = new Set(prev);
757 for (const key of folderKeys) {
758 if (next.delete(key)) changed = true;
759 }
760 return changed ? next : prev;
761 });
762 updateManuallyCollapsed((prev) => {
763 let changed = false;
764 const next = new Set(prev);
765 for (const key of folderKeys) {
766 if (!next.has(key)) {
767 next.add(key);
768 changed = true;
769 }
770 }
771 return changed ? next : prev;
772 });
773 }, [collapseSnapshot, expanded, folderKeys, hasExpandedFolders, manuallyCollapsed, resetTopicWindowLimits, searchActive, updateManuallyCollapsed]);
774
775 const openWorkbenchHeaderMenu = (
776 event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
777 menu: Exclude<WorkbenchHeaderMenu, null>,
778 ) => {
779 event.preventDefault();
780 event.stopPropagation();
781 setMenuNodeKey(null);
782 setMenuProject(null);
783 setConfirmArchiveTarget(null);
784 setConfirmRemoveProject(null);
785 setMenuPoint(contextMenuPointFromEvent(event));
786 setWorkbenchHeaderMenu((value) => (value === menu ? null : menu));
787 };
788 const handleCreateTopic = async (scope: string, workspaceRoot: string, key: string) => {
789 if (creatingRef.current) return;
790 creatingRef.current = true;
791 setCreatingProject(key);
792 setMenuProject(null);
793 setMenuPoint(null);
794 setExpanded((prev) => {
795 const next = new Set(prev);
796 next.add(key);
797 return next;
798 });
799 updateManuallyCollapsed((prev) => {
800 if (!prev.has(key)) return prev;
801 const next = new Set(prev);
802 next.delete(key);
803 return next;
804 });
805 try {
806 if (onCreateTopic) {
807 await onCreateTopic(scope, workspaceRoot);
808 await refresh();
809 await onTopicsChanged?.();
810 return;
811 }
812 const targetRoot = scope === "project" ? workspaceRoot : "";
813 const topic = await app.CreateTopic(scope, targetRoot, "");
814 await refresh();
815 await onTopicsChanged?.();
816 await onOpenTopic(scope, targetRoot, topic.id);
817 } catch {
818 /* ignore */
819 } finally {
820 creatingRef.current = false;
821 setCreatingProject(null);
822 }
823 };
824
825 const handleCreateIsolatedWorktree = async (workspaceRoot: string) => {
826 if (!workspaceRoot || isolatingProject) return;
827 setIsolatingProject(workspaceRoot);
828 closeMenu();
829 try {
830 await onCreateIsolatedWorktree?.(workspaceRoot);
831 } catch (err) {
832 showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
833 } finally {
834 setIsolatingProject(null);
835 }
836 };
837 const trashTopicAny = (node: ProjectNode) => remoteSessionActions.remove(node.topicId ?? "", () =>
838 node.sessionPath ? trashSession(node) : trashTopic(node.topicId ?? ""));
839 const startRenameTopic = (node: ProjectNode, label: string) => {
840 setMenuNodeKey(null);
841 setMenuProject(null);
842 setMenuPoint(null);
843 setConfirmArchiveTarget(null);
844 setEditingTopic(projectSessionRowKey(node));
845 setTopicDraft(label);
846 };
847
848 const startRenameSession = (key: string, path: string, topicId: string, label: string) => {
849 setMenuNodeKey(null);
850 setMenuProject(null);
851 setMenuPoint(null);
852 setConfirmArchiveTarget(null);
853 setEditingSession({ key, path, topicId });
854 setTopicDraft(label);
855 };
856
857 const startRenameProject = (key: string, root: string, label: string) => {
858 setMenuProject(null);
859 setMenuNodeKey(null);
860 setMenuPoint(null);
861 setConfirmRemoveProject(null);
862 setEditingProject({ key, root });
863 setProjectDraft(label);
864 };
865
866 const commitRenameTopic = async (node: ProjectNode) => {
867 const topicId = node.topicId ?? "";
868 const title = topicDraft.trim();
869 setEditingTopic(null);
870 if (!title) return;
871 try {
872 if (await remoteSessionActions.mutate(topicId, (remote) => app.RenameRemoteProjectSession(remote.hostId, remote.workspace, remoteSessionActionIdentity(remote), title))) return;
873 if (node.session || node.sessionPath) await app.RenameSessionTarget({ ref: node.session, source: node.source, sessionPath: node.sessionPath }, title);
874 else if (onRenameTopic) await onRenameTopic(topicId, title);
875 else await app.RenameTopic(topicId, title);
876 // Paint the new label immediately; the catalog event round-trip can lag.
877 setTree((current) => applyRuntimeProjection(node.session || node.sessionPath
878 ? projectTreeWithSessionTitle(current, node, title) : projectTreeWithTopicTitle(current, topicId, title)));
879 await refresh();
880 if (!onRenameTopic) await onTopicsChanged?.();
881 } catch (err) {
882 showToast(err instanceof Error ? err.message : String(err), "error");
883 }
884 };
885
886 const commitRenameSession = async () => {
887 const editing = editingSession;
888 const title = topicDraft.trim();
889 setEditingSession(null);
890 if (!editing || !title) return;
891 try {
892 await app.RenameSessionTarget({ sessionPath: editing.path, topicId: editing.topicId }, title);
893 await refresh();
894 await onTopicsChanged?.();
895 } catch (err) {
896 showToast(t(sessionTitleErrorKey(err)), "error");
897 }
898 };
899
900 const { renaming: aiRenamingTopics, rename: aiRenameSession } = useSessionTitleOperation(refresh, onTopicsChanged);
901
902 const commitRenameProject = async (root: string) => {
903 const title = projectDraft.trim();
904 setEditingProject(null);
905 if (!title) return;
906 try {
907 if (!await renameRemoteProjectTitle(root, title)) await app.RenameProject(root, title);
908 await refresh();
909 } catch (err) {
910 showToast(err instanceof Error ? err.message : String(err), "error");
911 }
912 };
913
914 const setTopicPinned = async (node: ProjectNode, pinned: boolean) => {
915 const topicId = node.topicId ?? "";
916 setMenuNodeKey(null);
917 setMenuPoint(null);
918 try {
919 if (await remoteSessionActions.mutate(topicId, (remote) => app.SetRemoteSessionPinned(remote.hostId, remote.workspace, remoteSessionActionIdentity(remote), pinned))) return;
920 if (node.session || node.sessionPath) await app.SetSessionPinned({ ref: node.session, source: node.source, sessionPath: node.sessionPath }, pinned);
921 else await app.SetTopicPinned(topicId, pinned);
922 await refresh();
923 await onTopicsChanged?.();
924 } catch (err) {
925 showToast(err instanceof Error ? err.message : String(err), "error");
926 }
927 };
928
929 const setProjectPinned = async (workspaceRoot: string, pinned: boolean) => {
930 if (!workspaceRoot) return;
931 try {
932 await app.SetProjectPinned(workspaceRoot, pinned);
933 setMenuProject(null);
934 setMenuPoint(null);
935 await refresh();
936 await onTopicsChanged?.();
937 } catch (err) {
938 showToast(err instanceof Error ? err.message : String(err), "error");
939 }
940 };
941
942 const copyProjectPath = async (path: string) => {
943 if (!path) return;
944 try {
945 await navigator.clipboard?.writeText(path);
946 } catch {
947 /* ignore */
948 }
949 };
950
951 const removeProject = async (path: string) => {
952 if (!path) return;
953 try {
954 await app.RemoveWorkspace(path);
955 setMenuProject(null);
956 setMenuPoint(null);
957 setConfirmRemoveProject(null);
958 await refresh();
959 await onTopicsChanged?.();
960 } catch (err) {
961 showToast(err instanceof Error ? err.message : String(err), "error");
962 }
963 };
964
965 const setProjectColor = async (path: string, color: string) => {
966 try {
967 await app.SetProjectColor(path, color);
968 setMenuProject(null);
969 setMenuPoint(null);
970 await refresh();
971 await onTopicsChanged?.();
972 } catch {
973 /* ignore */
974 }
975 };
976
977 const visibleTree = useMemo(() => {
978 const q = query.trim().toLowerCase();
979 const matchesQuery = (node: ProjectNode) =>
980 [node.label, node.root, node.topicId].some((value) => (value ?? "").toLowerCase().includes(q));
981 const filterNode = (node: ProjectNode, acceptedKeys?: ReadonlySet<string>): ProjectNode | null => {
982 const isFolder = node.kind === "project" || node.kind === "global_folder";
983 const folderAcceptedKeys = isFolder && q && !node.remote
984 ? new Set(topicPageState[projectTreeListKey(node.key, "", query)]?.itemKeys ?? [])
985 : undefined;
986 const children = asArray(node.children)
987 .map((child) => filterNode(child, folderAcceptedKeys ?? acceptedKeys))
988 .filter((child): child is ProjectNode => child !== null);
989 if (isFolder) {
990 if (children.length > 0 || matchesQuery(node)) return { ...node, children };
991 if (q) return null;
992 return node;
993 }
994 if (!q) return node;
995 if (acceptedKeys && (isTopicNode(node) || isRuntimeSessionNode(node)) && !node.pinned && !acceptedKeys.has(node.key)) return null;
996 if (q && !matchesQuery(node)) return null;
997 return node;
998 };
999 const filtered = treeWithRemoteSessions
1000 .map((node) => filterNode(node))
1001 .filter((node): node is ProjectNode => node !== null);
1002 if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchSortMode);
1003 return arrangeWorkbenchTree(filtered, "updated");
1004 }, [compactTopics, query, topicPageState, treeWithRemoteSessions, workbenchSortMode]);
1005
1006 const pinnedTreeSections = useMemo<PinnedTreeSections>(() => {
1007 if (creationTopics) return { pinned: [], projects: visibleTree };
1008 return splitPinnedProjectTree(visibleTree, workbenchSortMode, compactTopics);
1009 }, [compactTopics, creationTopics, visibleTree, workbenchSortMode]);
1010
1011 const projectDragEnabled = query.trim() === "";
1012
1013 const commitProjectReorder = useCallback(async (draggedRoot: string, targetRoot: string, position: ProjectDropPosition) => {
1014 const nextRoots = reorderedProjectRoots(tree, draggedRoot, targetRoot, position);
1015 const currentRoots = projectTreeProjectRoots(tree);
1016 if (nextRoots.join("\n") === currentRoots.join("\n")) return;
1017 setTree((current) => applyProjectOrder(current, nextRoots));
1018 try {
1019 await app.ReorderProjects(nextRoots);
1020 await refresh();
1021 await onTopicsChanged?.();
1022 } catch {
1023 await refresh();
1024 }
1025 }, [onTopicsChanged, refresh, tree]);
1026
1027 const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision, sortMode: creationTopics ? "updated" : workbenchSortMode });
1028
1029 const clearProjectDrag = useCallback(() => {
1030 setDragProjectRoot(null);
1031 setDropProject(null);
1032 }, []);
1033
1034 useEffect(() => {
1035 if (!dragProjectRoot) return;
1036 window.addEventListener("dragend", clearProjectDrag);
1037 window.addEventListener("drop", clearProjectDrag);
1038 window.addEventListener("blur", clearProjectDrag);
1039 return () => {
1040 window.removeEventListener("dragend", clearProjectDrag);
1041 window.removeEventListener("drop", clearProjectDrag);
1042 window.removeEventListener("blur", clearProjectDrag);
1043 };
1044 }, [clearProjectDrag, dragProjectRoot]);
1045
1046 const activeAncestorKeys = useMemo(
1047 () => activeRemote ? activeRemoteProjectAncestorKeys(treeWithRemoteSessions, activeRemote, projectNodeKey) : activeSessionAncestorKeys(treeWithRemoteSessions, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
1048 [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, treeWithRemoteSessions],
1049 );
1050 const activeNavigationKey = useMemo(() => activeRemote
1051 ? `remote\u001f${JSON.stringify(activeRemote)}`
1052 : activeTopicId
1053 ? [activeScope, activeWorkspaceRoot ?? "", activeTopicId].join("\u001f")
1054 : "", [activeRemote, activeScope, activeTopicId, activeWorkspaceRoot]);
1055 const followedActiveNavigationRef = useRef("");
1056 useEffect(() => {
1057 if (!activeNavigationKey) {
1058 followedActiveNavigationRef.current = "";
1059 return;
1060 }
1061 if (followedActiveNavigationRef.current === activeNavigationKey || activeAncestorKeys.length === 0) return;
1062 followedActiveNavigationRef.current = activeNavigationKey;
1063 const ancestorSet = new Set(activeAncestorKeys);
1064 updateManuallyCollapsed((prev) => {
1065 if (!activeAncestorKeys.some((key) => prev.has(key))) return prev;
1066 const next = new Set(prev);
1067 for (const key of activeAncestorKeys) next.delete(key);
1068 return next;
1069 });
1070 setExpanded((prev) => {
1071 if (activeAncestorKeys.every((key) => prev.has(key))) return prev;
1072 return new Set([...prev, ...ancestorSet]);
1073 });
1074 }, [activeAncestorKeys, activeNavigationKey, updateManuallyCollapsed]);
1075 useEffect(() => {
1076 if (!activeNavigationKey || !projectTreeRef.current) return;
1077 const root = projectTreeRef.current;
1078 let frame = 0;
1079 let observer: MutationObserver | null = null;
1080 const reveal = () => {
1081 const row = root.querySelector<HTMLElement>(".project-tree__topic--active .project-tree__topic-main");
1082 if (!row) return false;
1083 if (typeof row.scrollIntoView === "function") row.scrollIntoView({ block: "nearest", inline: "nearest" });
1084 observer?.disconnect();
1085 observer = null;
1086 return true;
1087 };
1088 frame = requestAnimationFrame(() => {
1089 if (reveal()) return;
1090 observer = new MutationObserver(() => { reveal(); });
1091 observer.observe(root, { childList: true, subtree: true });
1092 });
1093 return () => {
1094 cancelAnimationFrame(frame);
1095 observer?.disconnect();
1096 };
1097 }, [activeNavigationKey]);
1098 useEffect(() => {
1099 if (activeAncestorKeys.length === 0) return;
1100 setExpanded((prev) => {
1101 let changed = false;
1102 const next = new Set(prev);
1103 for (const key of activeAncestorKeys) {
1104 if (manuallyCollapsed.has(key) || next.has(key)) continue;
1105 next.add(key);
1106 changed = true;
1107 }
1108 return changed ? next : prev;
1109 });
1110 }, [activeAncestorKeys, manuallyCollapsed]);
1111
1112 const projectTreeDiagnosticSnapshot = useMemo<ProjectTreeDiagnosticSnapshot>(() => {
1113 const sessionSummary = summarizeProjectTreeSessions({
1114 tree,
1115 visibleTree,
1116 expanded,
1117 queryActive: query.trim().length > 0,
1118 expandedWindowCount: Object.values(topicWindowLimits).filter((limit) => limit > PROJECT_TREE_WINDOW_INITIAL).length,
1119 folderProjection: (folder, children) => {
1120 const groups = organization.groupsFor(folder);
1121 const scopedRows = (groupID: string, members: ProjectNode[]) => {
1122 if (folder.remote) return members;
1123 const accepted = new Set(topicListState(folder, query.trim() ? "" : groupID)?.itemKeys ?? []);
1124 const rows = members.filter((member) => accepted.has(member.key));
1125 if (!query.trim()) {
1126 const active = members.find((child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath));
1127 if (active && !rows.some((row) => row.key === active.key)) rows.push(active);
1128 }
1129 return rows;
1130 };
1131 const ungrouped = children.filter((child) => !groups.some((group) => projectTreeGroupContainsNode(group, child)));
1132 const ungroupedRows = scopedRows("", ungrouped);
1133 const visible = projectTreeWindowRows(
1134 ungroupedRows,
1135 query.trim() ? ungroupedRows.length : topicListLimit(folder),
1136 (child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
1137 );
1138 const collapsed: ProjectNode[] = [];
1139 for (const group of groups) {
1140 const members = children.filter((child) => projectTreeGroupContainsNode(group, child));
1141 const groupRows = scopedRows(group.id, members);
1142 if (!query.trim() && organization.groupCollapsed(projectTreeOrganizationKey(folder), group.id)) {
1143 collapsed.push(...groupRows);
1144 continue;
1145 }
1146 visible.push(...projectTreeWindowRows(
1147 groupRows,
1148 query.trim() ? groupRows.length : topicListLimit(folder, group.id),
1149 (child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
1150 ));
1151 }
1152 return { visible, collapsed };
1153 },
1154 projectNodeKey,
1155 isActive: (node) => topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote),
1156 isUnread: (node) => projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, readBaselineAt),
1157 });
1158 return {
1159 ...sessionSummary,
1160 directoryState: catalogStatus.state,
1161 scope: activeScope === "global" ? "global" : activeScope ? "project" : "unknown",
1162 variant,
1163 queryActive: query.trim().length > 0,
1164 catalogPartial: catalogStatus.state !== "ready"
1165 || (catalogStatus.repairActive ?? 0) > 0
1166 || (catalogStatus.unindexedTargetCount ?? 0) > 0
1167 || Boolean(catalogStatus.lastError),
1168 catalogRebuilding: catalogStatus.state === "rebuilding",
1169 catalogRevision: catalogStatus.revision,
1170 catalogIndexed: catalogStatus.indexed,
1171 catalogTotal: catalogStatus.total,
1172 unloadedSessions: Math.max(0, catalogStatus.total - sessionSummary.workspaceSessions),
1173 repairPending: catalogStatus.repairPending,
1174 treeRevision: latestRevisionRef.current,
1175 organizationRevision,
1176 };
1177 }, [activeRemote, activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, catalogStatus, expanded, organization, organizationRevision, query, readActivity, readBaselineAt, topicListLimit, topicListState, topicWindowLimits, tree, variant, visibleTree]);
1178
1179 useProjectTreeFrontendDiagnostics(projectTreeDiagnosticSnapshot);
1180
1181 const renderNode = (node: ProjectNode | null | undefined, depth: number, section: "pinned" | "projects" = "projects", isVisible = true) => {
1182 if (!node) return null;
1183 const key = projectNodeKey(node, depth);
1184 const children = asArray(node.children);
1185 const isExpanded = query.trim() ? true : expanded.has(key);
1186 const hasChildren = children.length > 0;
1187 // Snapshot rows are shells with no children until the first page is loaded,
1188 // so every project folder must remain expandable while indexing.
1189 const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded, true);
1190
1191 if (isTopicNode(node) || isRuntimeSessionNode(node)) {
1192 const isSessionNode = isRuntimeSessionNode(node);
1193 const openRequest = projectTreeTopicOpenRequest(node);
1194 const scope = openRequest?.scope ?? "project";
1195 const scopeClass = scope === "global" ? " project-tree__topic--global" : " project-tree__topic--project";
1196 const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
1197 const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, activeRemote);
1198 const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
1199 const activityAt = node.lastActivityAt || node.createdAt || 0;
1200 // Every variant is a single-line row with the activity time on the right;
1201 // turns and the exact date ride on the row's title instead of a second
1202 // meta line.
1203 const sideTimeVisible = true;
1204 const timeLabel = activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t);
1205 const exactTimeLabel = activityAt ? topicActivityDateLabel(activityAt) : "";
1206 const metaFull = projectTreeTopicMetaLine(node, t, compactTopics);
1207 const status = topicStatus(node);
1208 const statusLabel = topicStatusLabel(node, t);
1209 const archiveBlocked = projectTreeTopicArchiveBlocked(node);
1210 const waitingConfirmation = status === "waiting_confirmation";
1211 // Compact workbench: waiting shows an amber "待确认" pill instead of a
1212 // spinning orange dot, and that pill replaces the relative time so the
1213 // paused-for-user state is scannable in the background tab list.
1214 const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
1215 const showWaitingPill = waitingConfirmation || status === "finishing" || status === "cancelling" || status === "unknown";
1216 const showSideTime = sideTimeVisible && !showWaitingPill;
1217 const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, readBaselineAt);
1218 const topicId = node.topicId ?? "";
1219 const aiRenameTarget = sessionTitleTarget(node);
1220 const topicTrashing = trashingTopics.has(topicId);
1221 const sessionPath = node.sessionPath?.trim() ?? "";
1222 const sessionTrashing = trashingSessions.has(projectSessionIdentity(node));
1223 const archiveTargetKey = sessionPath ? projectTreeSessionArchiveTargetKey(sessionPath) : projectTreeTopicArchiveTargetKey(scope, node.root ?? "", topicId);
1224 const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
1225 const imSourceLabel = imSource?.label || "";
1226 const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
1227 const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
1228 const recoveryLabel = node.recoveryState === "recovery_only"
1229 ? t("projectTree.recoveryOnly")
1230 : node.recovered
1231 ? t("projectTree.recovered")
1232 : "";
1233 const forkedFromLabel = node.sessionOrigin === "fork" && node.parentSession?.sessionId
1234 ? t("projectTree.forkedFrom", { source: node.parentSession.sessionId })
1235 : "";
1236 const title = [node.preview || "", label, forkedFromLabel, recoveryLabel, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
1237 const topicMenuOpen = menuNodeKey === key;
1238 const pinned = Boolean(node.pinned);
1239 const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
1240 const openTopicMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
1241 event.preventDefault();
1242 event.stopPropagation();
1243 setMenuProject(null);
1244 setConfirmRemoveProject(null);
1245 setMenuPoint(contextMenuPointFromEvent(event));
1246 setMenuNodeKey(key);
1247 setConfirmArchiveTarget(null);
1248 if (!node.sessionPath && !node.remoteSession && (!node.session?.hostId || node.session.hostId === "local") && node.topicId) void inspectTopicRemoval(node.topicId).catch(() => undefined);
1249 };
1250 const topicMenuItems: ContextMenuItem[] = [
1251 ...organization.topicMenuItems(node, t),
1252 ...(projectTreeTopicMenuOffersPin(variant)
1253 ? [
1254 {
1255 key: pinned ? "unpin" : "pin",
1256 icon: <Pin size={13} />,
1257 label: pinLabel,
1258 onSelect: () => void setTopicPinned(node, !pinned),
1259 },
1260 ]
1261 : []),
1262 {
1263 key: "rename",
1264 icon: <Pencil size={13} />,
1265 label: t("projectTree.renameTopic"),
1266 onSelect: () => startRenameTopic(node, label),
1267 },
1268 {
1269 key: "aiRename",
1270 icon: <Sparkles size={13} />,
1271 label: aiRenamingTopics.has(aiRenameTarget) ? t("projectTree.aiRenamingTopic") : t("projectTree.aiRenameTopic"),
1272 disabled: aiRenamingTopics.has(aiRenameTarget) || !aiRenameTarget || Boolean(node.remoteSession) || Boolean(node.session?.hostId && node.session.hostId !== "local"),
1273 onSelect: () => void aiRenameSession(aiRenameTarget),
1274 },
1275 {
1276 key: "trash",
1277 icon: <Archive className={topicTrashing || sessionTrashing ? "project-tree__archive-spinner" : undefined} size={13} />,
1278 label: topicRemovalInspections[node.topicId ?? ""]?.disposition === "discard_placeholder" ? t("projectTree.removeEmptyTopic") : confirmArchiveTarget === archiveTargetKey ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
1279 disabled: archiveBlocked || topicTrashing || sessionTrashing || remoteSessionArchiveBlocked(node.remoteSession),
1280 danger: true,
1281 onSelect: () => {
1282 if (!node.sessionPath && !node.remoteSession && (!node.session?.hostId || node.session.hostId === "local")) void trashTopicAny(node);
1283 else if (confirmArchiveTarget === archiveTargetKey) void trashTopicAny(node);
1284 else setConfirmArchiveTarget(archiveTargetKey);
1285 },
1286 },
1287 ];
1288 if ((!isSessionNode && editingTopic === key) || (isSessionNode && editingSession?.key === key)) {
1289 return (
1290 <div
1291 key={key}
1292 className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}${imSource ? " project-tree__topic--im-source" : ""}${metaFull ? " project-tree__topic--has-meta" : ""}`}
1293 style={{ paddingLeft: 14 + depth * 16 }}
1294 >
1295 <input
1296 autoFocus
1297 className="project-tree__topic-input"
1298 value={topicDraft}
1299 onChange={(event) => setTopicDraft(event.target.value)}
1300 onFocus={(event) => event.target.select()}
1301 onKeyDown={(event) => {
1302 if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
1303 if (event.key === "Enter") void (isSessionNode ? commitRenameSession() : commitRenameTopic(node));
1304 if (event.key === "Escape") {
1305 setEditingTopic(null);
1306 setEditingSession(null);
1307 }
1308 }}
1309 onBlur={() => void (isSessionNode ? commitRenameSession() : commitRenameTopic(node))}
1310 />
1311 </div>
1312 );
1313 }
1314 const shortcutIndex = showShortcutBadges && isVisible && topicIndexRef.current < 9 ? topicIndexRef.current + 1 : 0;
1315 if (shortcutIndex > 0) topicIndexRef.current++;
1316 // Collect visible topics in render order for shortcut navigation
1317 if (openRequest && isVisible) {
1318 visibleTopicsCollectorRef.current.push({
1319 scope: openRequest.scope,
1320 workspaceRoot: openRequest.workspaceRoot,
1321 topicId: openRequest.topicId,
1322 sessionPath: openRequest.sessionPath,
1323 });
1324 }
1325 const topicDrag = organization.topicRow(node, section === "pinned" || isSessionNode || Boolean(query || menuNodeKey || editingTopic || dragProjectRoot || creatingProject));
1326 const row = (
1327 <div
1328 className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${topicDrag.className}${sideTimeVisible && (timeLabel || showStatusInSide || showWaitingPill) ? " project-tree__topic--with-side" : metaFull ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
1329 style={accentStyle}
1330 {...topicDrag.props}
1331 onContextMenu={openTopicMenu}
1332 >
1333 <button
1334 type="button"
1335 className="project-tree__topic-main"
1336 data-topic-open-key={key}
1337 title={title}
1338 style={{ paddingLeft: 14 + depth * 16 }}
1339 onClick={() => {
1340 const remote = node.remoteSession ?? remoteSessionActions.resolve(topicId);
1341 if (!openRequest && !remote) return;
1342 const nextClick = { rowKey: key, canRename: !isSessionNode };
1343 const pending = clickTimerRef.current;
1344 if (pending !== null) {
1345 clearTimeout(pending.timer);
1346 clickTimerRef.current = null;
1347 if (projectTreeShouldSuppressOpenForRename(pending, nextClick)) return;
1348 }
1349 const timer = setTimeout(() => {
1350 if (clickTimerRef.current?.timer !== timer) return;
1351 clickTimerRef.current = null;
1352 if (remote) {
1353 if (openRemoteSessionNode(remote, openRemoteProject)) markNodeRead(node);
1354 return;
1355 }
1356 if (openRequest) void Promise.resolve()
1357 .then(() => onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath))
1358 .then(() => markNodeRead(node))
1359 .catch((error) => showToast(String(error), "error"));
1360 }, 200);
1361 clickTimerRef.current = { ...nextClick, timer };
1362 }}
1363 onKeyDown={(event) => {
1364 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
1365 openTopicMenu(event);
1366 }
1367 }}
1368 onDoubleClick={(event) => {
1369 if (isSessionNode) return;
1370 event.stopPropagation();
1371 if (clickTimerRef.current !== null && clickTimerRef.current.rowKey === key) {
1372 clearTimeout(clickTimerRef.current.timer);
1373 clickTimerRef.current = null;
1374 }
1375 startRenameTopic(node, label);
1376 }}
1377 >
1378 <span className="project-tree__topic-copy">
1379 <span className="project-tree__topic-heading">
1380 <span className="project-tree__topic-label">{label}</span>
1381 <ProjectTreeSessionBadges node={node} forkedFromLabel={forkedFromLabel} recoveryLabel={recoveryLabel} />
1382 {imSource && (
1383 <span
1384 className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
1385 title={imSourceTitle}
1386 aria-label={imSourceTitle}
1387 >
1388 <MessageSquare size={11} />
1389 <span>{imSourceLabel}</span>
1390 </span>
1391 )}
1392 {!compactTopics && statusLabel && (
1393 <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>
1394 )}
1395 </span>
1396 </span>
1397 {sideTimeVisible && (
1398 <span className={`project-tree__topic-side${!timeLabel && !showStatusInSide && !showWaitingPill ? " project-tree__topic-side--empty" : ""}`}>
1399 {showWaitingPill && statusLabel ? (
1400 <span
1401 className="project-tree__topic-waiting-pill"
1402 title={statusLabel}
1403 >
1404 {statusLabel}
1405 </span>
1406 ) : (
1407 <>
1408 {showStatusInSide && (
1409 <span
1410 className={`project-tree__topic-state project-tree__topic-state--${status}`}
1411 title={statusLabel}
1412 aria-hidden="true"
1413 />
1414 )}
1415 {showSideTime && timeLabel && (
1416 <span className="project-tree__topic-time" aria-hidden="true">{timeLabel}</span>
1417 )}
1418 </>
1419 )}
1420 </span>
1421 )}
1422 {compactTopics && statusLabel && !showWaitingPill && (
1423 <span className="sr-only">
1424 {statusLabel}
1425 </span>
1426 )}
1427 {compactTopics && metaFull && (
1428 <span className="sr-only">
1429 {metaFull}
1430 </span>
1431 )}
1432 </button>
1433 {unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
1434 {projectTreeShouldRenderTopicActions(isSessionNode, variant, unread) && !(node.remoteSession && !node.remoteSession.name) && (
1435 <span
1436 className="project-tree__topic-actions"
1437 aria-label={t("projectTree.topicActions")}
1438 >
1439 <Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
1440 <button
1441 className={`project-tree__topic-action${pinned ? " project-tree__topic-action--pinned" : ""}`}
1442 type="button"
1443 aria-label={pinLabel}
1444 aria-pressed={pinned}
1445 onClick={(event) => {
1446 event.preventDefault();
1447 event.stopPropagation();
1448 void setTopicPinned(node, !pinned);
1449 }}
1450 >
1451 <Pin size={15} aria-hidden="true" />
1452 </button>
1453 </Tooltip>
1454 <Tooltip label={t("projectTree.archiveTopic")} side="top" className="project-tree__topic-action-slot">
1455 <button
1456 className={`project-tree__topic-action project-tree__topic-action--archive${topicTrashing ? " project-tree__topic-action--busy" : ""}`}
1457 type="button"
1458 aria-label={t("projectTree.archiveTopic")}
1459 aria-busy={topicTrashing} disabled={archiveBlocked || topicTrashing}
1460 onClick={(event) => {
1461 event.preventDefault();
1462 event.stopPropagation();
1463 void trashTopicAny(node);
1464 }}
1465 >
1466 <Archive className={topicTrashing ? "project-tree__archive-spinner" : undefined} size={15} aria-hidden="true" />
1467 </button>
1468 </Tooltip>
1469 </span>
1470 )}
1471 {isSessionNode ? (
1472 <ProjectTreeSessionArchiveMenu
1473 open={topicMenuOpen} point={menuPoint} sessionPath={sessionPath} blocked={archiveBlocked || topicTrashing} busy={sessionTrashing} confirmed={confirmArchiveTarget === archiveTargetKey}
1474 aiBusy={aiRenamingTopics.has(aiRenameTarget)}
1475 onRename={() => startRenameSession(key, sessionPath, topicId, label)}
1476 onAIRename={() => void aiRenameSession(aiRenameTarget)}
1477 onConfirm={() => setConfirmArchiveTarget(archiveTargetKey)} onTrash={() => { setConfirmArchiveTarget(null); void trashSession(node); }} onClose={closeMenu} />
1478 ) : <ContextMenu open={topicMenuOpen} point={menuPoint} items={topicMenuItems} minWidth={178} ariaLabel={t("projectTree.topicActions")} onClose={closeMenu} />}
1479 {shortcutIndex > 0 && (
1480 <span className="project-tree__topic-shortcut" aria-hidden="true">
1481 {topicShortcutLabel(shortcutIndex, shortcutPlatform)}
1482 </span>
1483 )}
1484 </div>
1485 );
1486 return (
1487 <div key={key}>
1488 {row}
1489 {hasChildren && (
1490 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1491 <div className="project-tree__children-inner">
1492 {children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
1493 </div>
1494 </div>
1495 )}
1496 </div>
1497 );
1498 }
1499
1500 const scope = node.kind === "global_folder" ? "global" : "project";
1501 const scopeClass = scope === "global" ? " project-tree__folder--global" : " project-tree__folder--project";
1502 const pinnedClass = node.pinned ? " project-tree__folder--pinned" : "";
1503 const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
1504 const projectRoot = scope === "global" ? "" : node.root ?? "";
1505 const projectDragKey = scope === "global" ? GLOBAL_PROJECT_ORDER_KEY : projectRoot;
1506 const projectPath = node.root ?? "";
1507 const colorTargetRoot = scope === "global" ? "" : projectPath;
1508 const projectLabel = scope === "global" && !node.remote ? defaultWorkspaceTitle(node.label) : node.label || "Untitled";
1509 const projectPinned = Boolean(node.pinned);
1510 const projectActive = node.remote ? Boolean(activeRemote && remoteProjectKey(activeRemote) === remoteProjectKey(node.remote)) : activeScope === scope && (scope === "global" || activeWorkspaceRoot === node.root);
1511 const projectMenuOpen = menuProject?.key === key;
1512 const activeTopicInProject = Boolean(activeTopicId) && activeScope === scope && (scope === "global" || activeWorkspaceRoot === projectRoot);
1513 const sourceProjectNode = tree.find((candidate) => scope === "global"
1514 ? candidate.kind === "global_folder"
1515 : candidate.kind === "project" && candidate.root === projectRoot);
1516 const activeTopicArchiveBlocked = asArray(sourceProjectNode?.children).some((candidate) =>
1517 isTopicNode(candidate) && candidate.topicId === activeTopicId && projectTreeTopicArchiveBlocked(candidate));
1518 const draggableProject = section !== "pinned" && projectDragEnabled && depth === 0 && Boolean(projectDragKey) && editingProject?.key !== key;
1519 const projectDropPosition = dropProject?.root === projectDragKey ? dropProject?.position ?? null : null;
1520 const handleProjectDragStart = (event: ReactDragEvent<HTMLElement>) => {
1521 if (!draggableProject) return;
1522 const target = event.target;
1523 if (target instanceof Element && target.closest(".project-tree__action-slot,.project-tree__folder-action-slot")) {
1524 event.preventDefault();
1525 return;
1526 }
1527 event.dataTransfer.effectAllowed = "move";
1528 event.dataTransfer.setData("text/plain", projectDragKey);
1529 setDragProjectRoot(projectDragKey);
1530 setDropProject(null);
1531 };
1532 const handleProjectDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
1533 if (!draggableProject || !dragProjectRoot || dragProjectRoot === projectDragKey) return;
1534 event.preventDefault();
1535 event.dataTransfer.dropEffect = "move";
1536 const rect = event.currentTarget.getBoundingClientRect();
1537 const position: ProjectDropPosition = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
1538 setDropProject((current) => {
1539 if (current?.root === projectDragKey && current?.position === position) return current;
1540 return { root: projectDragKey, position };
1541 });
1542 };
1543 const handleProjectDrop = (event: ReactDragEvent<HTMLDivElement>) => {
1544 if (!draggableProject) return;
1545 const draggedRoot = dragProjectRoot || event.dataTransfer.getData("text/plain");
1546 const position = dropProject?.root === projectDragKey ? dropProject?.position ?? "after" : "after";
1547 event.preventDefault();
1548 clearProjectDrag();
1549 if (draggedRoot && draggedRoot !== projectDragKey) void commitProjectReorder(draggedRoot, projectDragKey, position);
1550 };
1551 const openProjectMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
1552 event.preventDefault();
1553 event.stopPropagation();
1554 setMenuNodeKey(null);
1555 setConfirmArchiveTarget(null);
1556 setMenuPoint(contextMenuPointFromEvent(event));
1557 setMenuProject({ key, root: projectRoot, path: projectPath, scope, label: projectLabel });
1558 if (activeTopicId) void inspectTopicRemoval(activeTopicId).catch(() => undefined);
1559 setConfirmRemoveProject(null);
1560 if (scope === "project" && projectRoot) {
1561 void app.IsolatedWorktreeAvailability(projectRoot).then((availability) => {
1562 setWorktreeAvailability((current) => ({
1563 ...current,
1564 [projectRoot]: { available: availability.available, reason: availability.reason },
1565 }));
1566 }).catch(() => {});
1567 }
1568 };
1569 const isolationAvailability = worktreeAvailability[projectRoot];
1570 const isolatedWorkspaceItems: ContextMenuItem[] = scope === "project"
1571 ? [{
1572 key: "isolated-delivery-workspace",
1573 icon: <GitBranch size={13} />,
1574 label: (
1575 <span title={isolationAvailability?.reason || t("projectTree.createWorktreeHint")}>
1576 {isolatingProject === projectRoot ? t("projectTree.creatingWorktree") : t("projectTree.createWorktree")}
1577 </span>
1578 ),
1579 disabled: isolatingProject !== null || isolationAvailability?.available === false,
1580 onSelect: () => { void handleCreateIsolatedWorktree(projectRoot); },
1581 }]
1582 : [];
1583 const remoteProjectMenuItems = node.remote ? buildRemoteProjectMenuItems({ ref: node.remote, t, closeMenu, openRemoteProject, openRemoteWindow, setRemoteSessions, refresh, showToast }) : [];
1584 const newSessionMenuItem: ContextMenuItem = {
1585 key: "new-session",
1586 icon: <Plus size={13} />,
1587 label: t("projectTree.newTopic"),
1588 onSelect: () => { void handleCreateTopic(scope, projectPath, key); },
1589 };
1590 const projectMenuItems: ContextMenuItem[] = [
1591 {
1592 key: "new-group",
1593 icon: <FolderPlus size={13} />,
1594 label: t("projectTree.newGroup"),
1595 onSelect: () => organization.createGroup(node, t("projectTree.newGroup")),
1596 },
1597 newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
1598 ...isolatedWorkspaceItems,
1599 {
1600 key: "rename",
1601 icon: <Pencil size={13} />,
1602 label: t("projectTree.renameProject"),
1603 onSelect: () => startRenameProject(key, projectRoot, projectLabel),
1604 },
1605 { type: "separator" as const, key: "color-separator" },
1606 ...PROJECT_COLOR_OPTIONS.map((option): ContextMenuItem => ({
1607 key: `color-${option.key || "default"}`,
1608 label: colorMenuLabel(projectColorLabel(t, option.key), option.key, (node.projectColor || "") === option.key),
1609 onSelect: () => {
1610 void setProjectColor(colorTargetRoot, option.key);
1611 },
1612 })),
1613 { type: "separator" as const, key: "path-separator" },
1614 {
1615 key: "reveal",
1616 icon: <FolderOpen size={13} />,
1617 label: t(revealLabelKey(platform)),
1618 disabled: !projectPath,
1619 onSelect: () => {
1620 void app.RevealPath(projectPath).catch(() => {});
1621 closeMenu();
1622 },
1623 },
1624 {
1625 key: "copy-path",
1626 icon: <Copy size={13} />,
1627 label: t("projectTree.copyPath"),
1628 disabled: !projectPath,
1629 onSelect: () => {
1630 void copyProjectPath(projectPath);
1631 closeMenu();
1632 },
1633 },
1634 ...(scope === "project"
1635 ? [
1636 { type: "separator" as const, key: "remove-separator" },
1637 {
1638 key: "remove",
1639 icon: <XCircle size={13} />,
1640 label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProject") : t("projectTree.removeProject"),
1641 danger: true,
1642 onSelect: () => {
1643 if (confirmRemoveProject === key) void removeProject(projectPath);
1644 else setConfirmRemoveProject(key);
1645 },
1646 },
1647 ]
1648 : []),
1649 ];
1650 const workbenchProjectMenuItems: ContextMenuItem[] = [
1651 newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
1652 ...(scope === "project"
1653 ? [
1654 {
1655 key: projectPinned ? "unpin-project" : "pin-project",
1656 icon: <Pin size={13} />,
1657 label: t(projectPinned ? "projectTree.unpinProject" : "projectTree.pinProject"),
1658 onSelect: () => {
1659 void setProjectPinned(projectRoot, !projectPinned);
1660 },
1661 },
1662 ]
1663 : []),
1664 ...isolatedWorkspaceItems,
1665 {
1666 key: "reveal",
1667 icon: <FolderOpen size={13} />,
1668 label: t(revealLabelKey(platform)),
1669 disabled: !projectPath,
1670 onSelect: () => {
1671 void app.RevealPath(projectPath).catch(() => {});
1672 closeMenu();
1673 },
1674 },
1675 {
1676 key: "rename",
1677 icon: <Pencil size={13} />,
1678 label: t("projectTree.renameProjectWorkbench"),
1679 onSelect: () => startRenameProject(key, projectRoot, projectLabel),
1680 },
1681 {
1682 key: "archive-active-topic",
1683 icon: <Archive className={activeTopicId && trashingTopics.has(activeTopicId) ? "project-tree__archive-spinner" : undefined} size={13} />,
1684 label: topicRemovalInspections[activeTopicId ?? ""]?.disposition === "discard_placeholder" ? t("projectTree.removeEmptyTopic") : t("projectTree.archiveConversation"),
1685 disabled: !activeTopicInProject || !activeTopicId || activeTopicArchiveBlocked || Boolean(activeTopicId && trashingTopics.has(activeTopicId)),
1686 danger: true,
1687 onSelect: () => {
1688 if (!activeTopicId) return;
1689 void trashTopic(activeTopicId);
1690 },
1691 },
1692 ...(scope === "project"
1693 ? [
1694 { type: "separator" as const, key: "remove-separator" },
1695 {
1696 key: "remove",
1697 icon: <XCircle size={13} />,
1698 label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProjectShort") : t("projectTree.removeProjectShort"),
1699 danger: true,
1700 onSelect: () => {
1701 if (confirmRemoveProject === key) void removeProject(projectPath);
1702 else setConfirmRemoveProject(key);
1703 },
1704 },
1705 ]
1706 : []),
1707 ];
1708
1709 const backendPage = topicListState(node);
1710 const renderFolderChildren = () => {
1711 const hasGroups = organization.groupsFor(node).length > 0;
1712 if (!hasChildren && !hasGroups) {
1713 const remoteGroupKey = node.remote ? remoteProjectKey(node.remote) : "";
1714 const remoteBusy = Boolean(remoteGroupBusy[remoteGroupKey]);
1715 const remoteError = remoteGroupError[remoteGroupKey] || "";
1716 if (node.remote) return <RemoteProjectEmptyState
1717 busy={remoteBusy} error={remoteError} ready={remoteServers[node.remote.hostId]?.[node.remote.workspace]?.state === "ready"}
1718 isExpanded={isExpanded} depth={depth} t={t} onEnsure={() => ensureRemoteGroupSessions(node.remote!.hostId, node.remote!.workspace)}
1719 />;
1720 // While the first topic page is still loading (cold start, catalog
1721 // reconcile in flight), show a skeleton instead of a blank folder.
1722 if (projectTreeListShowsLoading(backendPage)) {
1723 return (
1724 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1725 <div className="project-tree__children-inner">
1726 <div className="project-tree__skeleton" style={{ paddingLeft: 14 + (depth + 1) * 16 }} aria-hidden="true">
1727 <span className="project-tree__skeleton-bar" />
1728 <span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
1729 <span className="project-tree__skeleton-bar" />
1730 <span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
1731 </div>
1732 </div>
1733 </div>
1734 );
1735 }
1736 if (!backendPage?.initialized) return null;
1737 }
1738 return (
1739 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1740 <div className="project-tree__children-inner">
1741 <ProjectTreeGroupRows
1742 folder={node} children={children} depth={depth + 1} section={section} visible={isVisible && isExpanded}
1743 organization={organization} renderNode={renderNode} t={t} queryActive={query.trim().length > 0}
1744 remote={Boolean(node.remote)} activeTopicId={activeTopicId} isActive={(child) => topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)}
1745 listState={(groupID) => topicListState(node, groupID)} listLimit={(groupID) => topicListLimit(node, groupID)}
1746 onEnsureList={(groupID) => ensureTopicList(node, groupID)} onExpandList={(groupID, loadedCount) => expandTopicList(node, groupID, loadedCount)}
1747 onRetryList={(groupID) => retryTopicList(node, groupID)}
1748 onForgetList={(groupID) => forgetTopicList(node, groupID)}
1749 />
1750 {query.trim() && backendPage?.nextCursor && (
1751 <button
1752 type="button"
1753 className="project-tree__topic-window-toggle"
1754 style={{ paddingLeft: 14 + (depth + 1) * 16 }}
1755 disabled={backendPage.loading}
1756 aria-label={`${t("projectTree.loadMoreResults")} · ${projectLabel}`}
1757 onClick={() => void loadProjectTopics(node, true)}
1758 >
1759 {projectTreeListShowsLoading(backendPage) ? t("projectTree.loadingMore") : t("projectTree.loadMoreResults")}
1760 </button>
1761 )}
1762 </div>
1763 </div>
1764 );
1765 };
1766
1767 if (editingProject?.key === key) {
1768 return (
1769 <div key={key} className="project-tree__project-wrapper">
1770 <div
1771 className={`project-tree__folder project-tree__folder--editing${projectActive ? " project-tree__folder--active" : ""}`}
1772 style={{ paddingLeft: 8 + depth * 16 }}
1773 >
1774 <input
1775 autoFocus
1776 className="project-tree__folder-input"
1777 value={projectDraft}
1778 onChange={(event) => setProjectDraft(event.target.value)}
1779 onKeyDown={(event) => {
1780 if (event.key === "Enter") void commitRenameProject(projectRoot);
1781 if (event.key === "Escape") setEditingProject(null);
1782 }}
1783 onBlur={() => void commitRenameProject(projectRoot)}
1784 />
1785 </div>
1786 {renderFolderChildren()}
1787 </div>
1788 );
1789 }
1790
1791 return (
1792 <div key={key} className="project-tree__project-wrapper">
1793 <div
1794 className={`project-tree__folder${scopeClass}${pinnedClass}${draggableProject ? " project-tree__folder--draggable" : ""}${projectActive ? " project-tree__folder--active" : ""}${projectMenuOpen ? " project-tree__folder--menu-open" : ""}${dragProjectRoot === projectDragKey ? " project-tree__folder--dragging" : ""}${projectDropPosition ? ` project-tree__folder--drop-${projectDropPosition}` : ""}`}
1795 style={accentStyle}
1796 draggable={draggableProject}
1797 aria-grabbed={draggableProject ? dragProjectRoot === projectRoot : undefined}
1798 onDragStart={handleProjectDragStart}
1799 onDragOver={handleProjectDragOver}
1800 onDragLeave={(event) => {
1801 if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropProject(null);
1802 }}
1803 onDrop={handleProjectDrop}
1804 onDragEnd={clearProjectDrag}
1805 onContextMenu={openProjectMenu}
1806 >
1807 <button
1808 type="button"
1809 className="project-tree__folder-main"
1810 title={scope === "global" && !node.remote ? t("workspace.defaultHint") : undefined}
1811 style={{ paddingLeft: 8 + depth * 16 }}
1812 onClick={() => {
1813 if (node.remote && !folderDisclosure.canExpand) return void openRemoteProject(node.remote, { focus: true });
1814 if (folderDisclosure.canExpand) {
1815 const willExpand = !expanded.has(key);
1816 toggleExpand(key, node);
1817 if (node.remote && willExpand) { void ensureRemoteGroupSessions(node.remote.hostId, node.remote.workspace); }
1818 }
1819 }}
1820 onKeyDown={(event) => {
1821 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
1822 openProjectMenu(event);
1823 }
1824 }}
1825 aria-expanded={folderDisclosure.ariaExpanded}
1826 >
1827 <span className={folderDisclosure.iconStackClassName}>
1828 {node.remote ? <Cloud size={14} className="project-tree__folder-icon" /> : folderDisclosure.isOpen ? <FolderOpen size={14} className="project-tree__folder-icon" /> : <Folder size={14} className="project-tree__folder-icon" />}
1829 </span>
1830 <span className="project-tree__folder-color" aria-hidden="true" />
1831 <span className={`project-tree__folder-label${!hasChildren ? " project-tree__folder-label--empty" : ""}`}>
1832 {projectLabel}
1833 {node.isolatedWorktree && <WorktreeBadge size={11} />}
1834 {node.remote ? <span className={`project-tree__remote-badge project-tree__remote-badge--${remoteServeBadgeState(remoteServers[node.remote.hostId]?.[node.remote.workspace], remoteGroupBusy[remoteProjectKey(node.remote)])}`} aria-hidden="true" /> : null}
1835 </span>
1836 <ProjectTreeFolderActivity folder={node} />
1837 </button>
1838 {compactTopics && (
1839 <Tooltip label={t("projectTree.projectActions")} className="project-tree__folder-action-slot">
1840 <button
1841 type="button"
1842 className="project-tree__folder-action project-tree__folder-action--menu"
1843 aria-label={t("projectTree.projectActions")}
1844 aria-haspopup="menu"
1845 aria-expanded={projectMenuOpen}
1846 onClick={(e) => {
1847 openProjectMenu(e);
1848 }}
1849 >
1850 <MoreHorizontal size={16} aria-hidden="true" />
1851 </button>
1852 </Tooltip>
1853 )}
1854 {!node.remote && <Tooltip label={t("projectTree.newTopicTooltip")} className={compactTopics ? "project-tree__folder-action-slot" : "project-tree__action-slot"}>
1855 <button
1856 type="button"
1857 className={compactTopics
1858 ? `project-tree__folder-action project-tree__folder-action--create${creatingProject === key ? " project-tree__folder-action--active" : ""}`
1859 : `project-tree__new-topic${creatingProject === key ? " project-tree__new-topic--active" : ""}`}
1860 aria-label={t("projectTree.newTopicTooltip")}
1861 disabled={creatingProject !== null}
1862 onClick={(e) => {
1863 e.stopPropagation();
1864 void handleCreateTopic(scope, projectPath, key);
1865 }}
1866 >
1867 {compactTopics ? <Plus size={15} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
1868 </button>
1869 </Tooltip>}
1870 <ContextMenu
1871 open={projectMenuOpen}
1872 point={menuPoint}
1873 items={node.remote ? remoteProjectMenuItems : compactTopics ? workbenchProjectMenuItems : projectMenuItems}
1874 minWidth={compactTopics ? 206 : 212}
1875 ariaLabel={t("projectTree.projectActions")}
1876 onClose={closeMenu}
1877 />
1878 </div>
1879 {renderFolderChildren()}
1880 </div>
1881 );
1882 };
1883
1884 const workbenchHeaderMoreItems: ContextMenuItem[] = [
1885 {
1886 key: "sort-heading",
1887 icon: <Clock size={13} />,
1888 label: t("projectTree.sortCriteria"),
1889 disabled: true,
1890 variant: "section",
1891 onSelect: () => {},
1892 },
1893 {
1894 key: "sort-created",
1895 icon: <Clock size={13} />,
1896 label: menuLabelWithCheck(t("projectTree.sortByCreatedAt"), workbenchSortMode === "created"),
1897 onSelect: () => {
1898 selectWorkbenchSortMode("created");
1899 },
1900 },
1901 {
1902 key: "sort-updated",
1903 icon: <Pencil size={13} />,
1904 label: menuLabelWithCheck(t("projectTree.sortByUpdatedAt"), workbenchSortMode === "updated"),
1905 onSelect: () => {
1906 selectWorkbenchSortMode("updated");
1907 },
1908 },
1909 ];
1910
1911 const addItemCallbacks = {
1912 onBlank: () => { closeMenu(); openBlankProjectFlow(); },
1913 onLocal: () => { closeMenu(); void handleAddProject(); },
1914 onRemote: () => { closeMenu(); openRemoteConnectFlow(); },
1915 };
1916 const classicHeaderAddItems = projectTreeHeaderAddItems({
1917 localLabel: t("projectTree.addProjectTooltip"), remoteLabel: t("projectTree.remoteConnection"), disabled: addingProject, ...addItemCallbacks,
1918 });
1919 const workbenchHeaderAddItems = projectTreeHeaderAddItems({
1920 blankLabel: t("projectTree.createBlankProject"), localLabel: t("projectTree.useExistingFolder"), remoteLabel: t("projectTree.remoteConnection"), disabled: addingProject, ...addItemCallbacks,
1921 });
1922
1923 const renderProjectHeader = (mode: "classic" | "workbench") => (
1924 <div className="project-tree__header">
1925 <span className="project-tree__header-title">
1926 <BriefcaseBusiness className="project-tree__header-icon" size={13} />
1927 {t("projectTree.workspaceTitle")}
1928 </span>
1929 <span className="project-tree__header-actions">
1930 {mode === "workbench" ? (
1931 <>
1932 <Tooltip label={workbenchCollapseToggleLabel} className="project-tree__header-action-slot">
1933 <button
1934 type="button"
1935 className="project-tree__header-icon-btn"
1936 aria-label={workbenchCollapseToggleLabel}
1937 disabled={!canToggleCollapsedView}
1938 onClick={toggleCollapsedView}
1939 >
1940 {canRestoreCollapsedView ? <Maximize2 size={15} aria-hidden="true" /> : <Minimize2 size={15} aria-hidden="true" />}
1941 </button>
1942 </Tooltip>
1943 <span className="project-tree__header-menu-wrap">
1944 <Tooltip label={t("projectTree.moreActions")} className="project-tree__header-action-slot">
1945 <button
1946 type="button"
1947 className={`project-tree__header-icon-btn${workbenchHeaderMenu === "more" ? " project-tree__header-icon-btn--active" : ""}`}
1948 aria-label={t("projectTree.moreActions")}
1949 aria-haspopup="menu"
1950 aria-expanded={workbenchHeaderMenu === "more"}
1951 onClick={(event) => {
1952 openWorkbenchHeaderMenu(event, "more");
1953 }}
1954 >
1955 <MoreHorizontal size={16} aria-hidden="true" />
1956 </button>
1957 </Tooltip>
1958 <ContextMenu
1959 open={workbenchHeaderMenu === "more"}
1960 point={menuPoint}
1961 items={workbenchHeaderMoreItems}
1962 minWidth={222}
1963 ariaLabel={t("projectTree.moreActions")}
1964 onClose={closeMenu}
1965 />
1966 </span>
1967 <span className="project-tree__header-menu-wrap">
1968 <Tooltip label={t("projectTree.addProjectTooltip")} className="project-tree__header-action-slot">
1969 <button
1970 type="button"
1971 className={`project-tree__header-icon-btn${workbenchHeaderMenu === "add" ? " project-tree__header-icon-btn--active" : ""}`}
1972 aria-label={t("projectTree.addProjectTooltip")}
1973 aria-haspopup="menu"
1974 aria-expanded={workbenchHeaderMenu === "add"}
1975 disabled={addingProject}
1976 onClick={(event) => {
1977 openWorkbenchHeaderMenu(event, "add");
1978 }}
1979 >
1980 <FolderPlus size={16} aria-hidden="true" />
1981 </button>
1982 </Tooltip>
1983 <ContextMenu
1984 open={workbenchHeaderMenu === "add"}
1985 point={menuPoint}
1986 items={workbenchHeaderAddItems}
1987 minWidth={206}
1988 ariaLabel={t("projectTree.addProjectTooltip")}
1989 onClose={closeMenu}
1990 />
1991 </span>
1992 </>
1993 ) : (
1994 <>
1995 <Tooltip label={collapseToggleLabel} className="project-tree__action-slot project-tree__header-action-slot project-tree__action-slot--collapse">
1996 <button
1997 type="button"
1998 className={`project-tree__collapse-all${canRestoreCollapsedView ? " project-tree__collapse-all--restore" : ""}`}
1999 aria-label={collapseToggleLabel}
2000 aria-pressed={canRestoreCollapsedView}
2001 disabled={!canToggleCollapsedView}
2002 onClick={toggleCollapsedView}
2003 >
2004 {canRestoreCollapsedView ? <ListRestart size={14} /> : <ListCollapse size={14} />}
2005 </button>
2006 </Tooltip>
2007 <ProjectTreeHeaderAddControl
2008 open={workbenchHeaderMenu === "add"} point={menuPoint} items={classicHeaderAddItems}
2009 label={t("projectTree.addProjectTooltip")} disabled={addingProject}
2010 onOpen={(event) => openWorkbenchHeaderMenu(event, "add")} onClose={closeMenu}
2011 />
2012 </>
2013 )}
2014 </span>
2015 </div>
2016 );
2017
2018 const renderEmptyState = () => {
2019 if (shellStage !== "ready") return <div className="project-tree__empty-state" role="status"><div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.loadingProjects")}</div>
2020 {shellStage === "slow" && <div className="project-tree__skeleton" aria-hidden="true">
2021 <span className="project-tree__skeleton-bar" /><span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
2022 </div>}
2023 </div>;
2024 if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
2025 return (
2026 <div className="project-tree__empty-state">
2027 <div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.emptyNoProjects")}</div>
2028 <button
2029 type="button"
2030 className="project-tree__empty-primary"
2031 onClick={() => void handleAddProject()}
2032 disabled={addingProject}
2033 >
2034 <FolderPlus size={14} />
2035 <span>{t("projectTree.addProjectTooltip")}</span>
2036 </button>
2037 <ProjectTreeRemoteAction label={t("projectTree.remoteConnection")} disabled={addingProject} onClick={openRemoteConnectFlow} />
2038 </div>
2039 );
2040 };
2041
2042 const hasTreeRows = pinnedTreeSections.pinned.length > 0 || pinnedTreeSections.projects.length > 0;
2043
2044 // Report visible topics to parent after render so shortcuts match sidebar order.
2045 // eslint-disable-next-line react-hooks/exhaustive-deps
2046 useEffect(() => {
2047 onVisibleTopicsChange?.(visibleTopicsCollectorRef.current);
2048 });
2049
2050 // Reset topic index counter and visible topics collector before each render.
2051 topicIndexRef.current = 0;
2052 visibleTopicsCollectorRef.current = [];
2053 const catalogNotice = sessionCatalogNotice(catalogStatus);
2054 const catalogNoticeText = catalogNotice === "indexing"
2055 ? (catalogStatus.total <= 0 ? t("projectTree.indexing")
2056 : t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total }))
2057 : catalogNotice === "repair-active"
2058 ? t("projectTree.repairActive", { count: catalogStatus.repairActive ?? catalogStatus.repairPending })
2059 : catalogNotice === "repair-deferred"
2060 ? t("projectTree.repairDeferred")
2061 : catalogNotice === "repair-blocked"
2062 ? t("projectTree.repairBlocked", { count: catalogStatus.repairBlocked ?? catalogStatus.repairPending })
2063 : `${t("projectTree.indexing")} — ${t("task.state.failed")}`;
2064
2065 return (
2066 <div ref={projectTreeRef} className="project-tree">
2067 {searchVisible && (
2068 <label className="project-tree__search">
2069 <Search size={14} />
2070 <input
2071 ref={searchInputRef}
2072 value={query}
2073 onChange={(event) => changeQuery(event.target.value)}
2074 placeholder={t("projectTree.searchPlaceholder")}
2075 />
2076 </label>
2077 )}
2078 {catalogNotice && (
2079 <div className="project-tree__catalog-progress" role="status">
2080 <span>{catalogNoticeText}</span>
2081 {catalogNotice === "rebuild" && (
2082 <button type="button" className="project-tree__catalog-rebuild" onClick={() => void rebuildSessionCatalog()}>
2083 {t("projectTree.rebuildCatalog")}
2084 </button>
2085 )}
2086 </div>
2087 )}
2088 {compactTopics ? (
2089 <>
2090 {renderProjectHeader("workbench")}
2091 <div className="project-tree__list project-tree__list--workbench">
2092 {!hasTreeRows ? (
2093 renderEmptyState()
2094 ) : (
2095 <>
2096 {pinnedTreeSections.pinned.length > 0 && (
2097 <div className="project-tree__section project-tree__section--pinned">
2098 <div className="project-tree__section-title project-tree__section-title--pinned">
2099 <Pin size={14} className="project-tree__section-title-icon" aria-hidden="true" />
2100 <span>{t("projectTree.pinnedTitle")}</span>
2101 </div>
2102 {pinnedTreeSections.pinned.map((node) => renderNode(node, 0, "pinned"))}
2103 </div>
2104 )}
2105 <div className="project-tree__section project-tree__section--projects">
2106 {pinnedTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
2107 </div>
2108 </>
2109 )}
2110 </div>
2111 </>
2112 ) : (
2113 <>
2114 {renderProjectHeader("classic")}
2115 <div className="project-tree__list">
2116 {!hasTreeRows ? (
2117 renderEmptyState()
2118 ) : (
2119 <>
2120 {pinnedTreeSections.pinned.length > 0 && (
2121 <div className="project-tree__section project-tree__section--pinned">
2122 <div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
2123 {pinnedTreeSections.pinned.map((node) => renderNode(node, 1, "pinned"))}
2124 </div>
2125 )}
2126 <div className="project-tree__section project-tree__section--projects">
2127 {pinnedTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
2128 </div>
2129 </>
2130 )}
2131 </div>
2132 </>
2133 )}
2134 {blankProjectFlow}
2135 {remoteConnectFlow}
2136 </div>
2137 );
2138 }
2139
2139 lines Plain Text