返回 DeepSeek-Reasonix
ProjectTree.tsx
根目录 / desktop / frontend / src / components / ProjectTree.tsx
1 // ProjectTree is the sidebar replacement for the flat recent-sessions list.
2 // It shows a tree of projects (each with expandable topics) plus a Global
3 // section. Clicking a topic opens its tab; "+" next to a project creates a
4 // new topic.
5 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6 import { createPortal } from "react-dom";
7 import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
8 import { Archive, ArrowDown, Pencil, Plus, Folder, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, Check, ListCollapse, ListRestart, MessageSquare, Clock, Pin, MoreHorizontal, Minimize2, Maximize2, GitBranch } from "lucide-react";
9 import { asArray } from "../lib/array";
10 import { useToast } from "../lib/toast";
11 import { app } from "../lib/bridge";
12 import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
13 import { topicActivityTime } from "../lib/session";
14 import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n";
15 import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
16 import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
17 import type { ShortcutPlatform } from "../lib/keyboardShortcuts";
18 import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
19 import { Tooltip } from "./Tooltip";
20 import { WorktreeBadge } from "./WorktreeBadge";
21
22 type ProjectTreeVariant = "classic" | "workbench" | "creation";
23
24 interface ProjectTreeProps {
25 activeScope?: string;
26 activeWorkspaceRoot?: string;
27 activeTopicId?: string;
28 activeSessionPath?: string;
29 imTopicSources?: Record<string, ProjectTreeImTopicSource>;
30 variant?: ProjectTreeVariant;
31 onOpenTopic: (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string) => Promise<void> | void;
32 onAddProject: () => Promise<void>;
33 onCreateTopic?: (scope: string, workspaceRoot: string) => Promise<void> | void;
34 onCreateDeliveryWorktree?: (workspaceRoot: string) => Promise<void> | void;
35 onRenameTopic?: (topicId: string, title: string) => Promise<void> | void;
36 onTopicsChanged?: () => Promise<void> | void;
37 refreshSignal?: number;
38 timeFilter: "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d";
39 onTimeFilterChange: (filter: "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d") => void;
40 searchExpanded?: boolean;
41 searchFocusSignal?: number;
42 showShortcutBadges?: boolean;
43 shortcutPlatform?: ShortcutPlatform;
44 onVisibleTopicsChange?: (topics: TopicShortcutEntry[]) => void;
45 }
46
47 type ProjectTreeImTopicSource = {
48 platform?: string;
49 label: string;
50 title?: string;
51 remoteId?: string;
52 };
53
54 function projectNodeKey(node: ProjectNode, depth: number): string {
55 return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`;
56 }
57
58 function isRuntimeSessionNode(node: ProjectNode): boolean {
59 return node.kind === "session" || node.kind === "global_session";
60 }
61
62 function isTopicNode(node: ProjectNode): boolean {
63 return node.kind === "topic" || node.kind === "global_topic";
64 }
65
66 export type ProjectTreeTopicOpenRequest = {
67 scope: "global" | "project";
68 workspaceRoot: string;
69 topicId: string;
70 sessionPath?: string;
71 };
72
73 export function projectTreeTopicOpenRequest(node: ProjectNode): ProjectTreeTopicOpenRequest | null {
74 if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return null;
75 const scope = node.kind === "global_topic" || node.kind === "global_session" ? "global" : "project";
76 return {
77 scope,
78 workspaceRoot: scope === "global" ? "" : node.root ?? "",
79 topicId: node.topicId ?? "",
80 sessionPath: node.sessionPath,
81 };
82 }
83
84 type ProjectTreeTopicClickTarget = {
85 rowKey: string;
86 canRename: boolean;
87 };
88
89 type ProjectTreePendingTopicOpen = ProjectTreeTopicClickTarget & {
90 timer: ReturnType<typeof setTimeout>;
91 };
92
93 export function projectTreeShouldSuppressOpenForRename(
94 pending: ProjectTreeTopicClickTarget | null,
95 next: ProjectTreeTopicClickTarget,
96 ): boolean {
97 return Boolean(pending && pending.rowKey === next.rowKey && pending.canRename && next.canRename);
98 }
99
100 export type ProjectTreeFolderDisclosure = {
101 canExpand: boolean;
102 isOpen: boolean;
103 ariaExpanded?: boolean;
104 iconStackClassName: string;
105 };
106
107 // allowEmptyExpand lets classic folders open without children so the expanded
108 // state can host the "no sessions" placeholder row; other variants keep the
109 // original contract where empty folders are inert.
110 export function projectTreeFolderDisclosure(hasChildren: boolean, isExpanded: boolean, allowEmptyExpand = false): ProjectTreeFolderDisclosure {
111 const canExpand = hasChildren || allowEmptyExpand;
112 const isOpen = canExpand && isExpanded;
113 return {
114 canExpand,
115 isOpen,
116 ariaExpanded: canExpand ? isExpanded : undefined,
117 iconStackClassName: `project-tree__icon-stack${canExpand ? " project-tree__icon-stack--expandable" : ""}`,
118 };
119 }
120
121 function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string, activeSessionPath?: string): boolean {
122 if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
123 if (node.sessionPath) return Boolean(activeSessionPath && activeSessionPath === node.sessionPath);
124 if (activeSessionPath && asArray(node.children).some(isRuntimeSessionNode)) return false;
125 const scope = node.kind === "global_topic" ? "global" : "project";
126 return (
127 activeTopicId === node.topicId &&
128 activeScope === scope &&
129 (scope === "global" || activeWorkspaceRoot === node.root)
130 );
131 }
132
133 export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
134 const parts: string[] = [];
135 const turns = node.turns ?? 0;
136 if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
137 const activityAt = node.lastActivityAt || node.createdAt || 0;
138 if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
139 if (parts.length === 0) parts.push(t("projectTree.previously"));
140 return parts.join(" · ");
141 }
142
143 // Model for the classic hover preview card: the row keeps a time-only meta
144 // line, so the card carries the full title, turns, exact date, and project.
145 export type ProjectTreeTopicHoverCard = {
146 title: string;
147 statusLabel: string;
148 metaLine: string;
149 exactTime: string;
150 projectLabel: string;
151 };
152
153 // Activity labels older than a week are already the calendar date (always the
154 // meta line's last part), so callers pairing the two keep a single copy.
155 export function projectTreeDedupedExactTime(metaLine: string, exactTime: string): string {
156 return exactTime && metaLine.endsWith(exactTime) ? "" : exactTime;
157 }
158
159 export function projectTreeTopicHoverCardModel(node: ProjectNode, t: Translator, projectLabel: string): ProjectTreeTopicHoverCard {
160 const activityAt = node.lastActivityAt || node.createdAt || 0;
161 const metaLine = projectTreeTopicMetaLine(node, t);
162 const exactTime = activityAt ? topicActivityDateLabel(activityAt) : "";
163 return {
164 title: (node.label || node.topicId || "Untitled").replace(/^●\s*/, ""),
165 statusLabel: topicStatusLabel(node, t),
166 metaLine,
167 exactTime: projectTreeDedupedExactTime(metaLine, exactTime),
168 projectLabel,
169 };
170 }
171
172 function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
173 return topicActivityAt(node) ? "" : t("projectTree.previously");
174 }
175
176 const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
177 thinking: "projectTree.status.thinking",
178 streaming: "projectTree.status.streaming",
179 waiting_confirmation: "projectTree.status.waitingConfirmation",
180 background_job: "projectTree.status.backgroundJob",
181 paused: "projectTree.status.paused",
182 error: "projectTree.status.error",
183 };
184
185 function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" {
186 if (!status) return "";
187 if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "error") {
188 return status;
189 }
190 return "";
191 }
192
193 function topicStatus(node: ProjectNode): ProjectTopicStatus | "" {
194 return normalizeTopicStatus(node.status) || (node.running ? "streaming" : "");
195 }
196
197 export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean {
198 if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true;
199 const status = normalizeTopicStatus(node.status);
200 if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true;
201 if (status === "paused" || status === "error") return false;
202 return Boolean(node.running);
203 }
204
205 function topicStatusLabel(node: ProjectNode, t: Translator): string {
206 const status = topicStatus(node);
207 return status ? t(topicStatusLabels[status]) : "";
208 }
209
210 function topicActivityAt(node: ProjectNode): number {
211 return node.lastActivityAt || node.createdAt || 0;
212 }
213
214 export function projectTreeReadActivityKey(node: ProjectNode): string | null {
215 const request = projectTreeTopicOpenRequest(node);
216 if (!request?.topicId) return null;
217 return [
218 request.scope,
219 request.workspaceRoot,
220 request.topicId,
221 request.sessionPath ?? "",
222 ].join("\u001f");
223 }
224
225 type ProjectTreeReadActivity = Record<string, number>;
226
227 export function projectTreeTopicHasUnreadActivity(
228 node: ProjectNode,
229 readActivity: ProjectTreeReadActivity,
230 activeScope?: string,
231 activeWorkspaceRoot?: string,
232 activeTopicId?: string,
233 activeSessionPath?: string,
234 ): boolean {
235 if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
236 if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
237 if (topicStatus(node) !== "") return false;
238 const key = projectTreeReadActivityKey(node);
239 const activityAt = topicActivityAt(node);
240 return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
241 }
242
243 export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, variant: ProjectTreeVariant, unread: boolean): boolean {
244 return !isSessionNode && variant !== "creation" && !unread;
245 }
246
247 // Pinning reorders the classic/workbench trees shared with creation mode, so
248 // the creation context menu keeps its original rename/trash-only entries.
249 export function projectTreeTopicMenuOffersPin(variant: ProjectTreeVariant): boolean {
250 return variant !== "creation";
251 }
252
253 function topicActivityLabel(ms: number, t: Translator, compact = false): string {
254 if (ms <= 0) return "";
255 const delta = Date.now() - ms;
256 const locale = getLocale();
257 const minute = 60_000;
258 const hour = 60 * minute;
259 const day = 24 * hour;
260 const month = 30 * day;
261 const year = 365 * day;
262 if (delta < minute) return t("projectTree.justNow");
263 if (!compact) {
264 const rtfLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
265 const rtf = new Intl.RelativeTimeFormat(rtfLocale, { numeric: "auto" });
266 if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute");
267 if (delta < day) return rtf.format(-Math.round(delta / hour), "hour");
268 if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day");
269 return topicActivityDateLabel(ms);
270 }
271 if (delta < hour) {
272 const value = Math.max(1, Math.round(delta / minute));
273 return locale === "zh" || locale === "zh-TW" ? `${value} 分钟` : `${value}m`;
274 }
275 if (delta < day) {
276 const value = Math.round(delta / hour);
277 return locale === "zh" || locale === "zh-TW" ? `${value} 小时` : `${value}h`;
278 }
279 if (delta < 7 * day) {
280 const value = Math.round(delta / day);
281 return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
282 }
283 if (delta < month) {
284 const value = Math.round(delta / day);
285 return locale === "zh" || locale === "zh-TW" ? `${value} 天` : `${value}d`;
286 }
287 if (delta < year) {
288 const value = Math.max(1, Math.round(delta / month));
289 return locale === "zh" || locale === "zh-TW" ? `${value} 个月` : `${value}mo`;
290 }
291 const value = Math.max(1, Math.round(delta / year));
292 return locale === "zh" || locale === "zh-TW" ? `${value} 年` : `${value}y`;
293 }
294
295 function topicActivityDateLabel(ms: number): string {
296 if (ms <= 0) return "";
297 const locale = getLocale();
298 const dateLocale = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
299 return new Date(ms).toLocaleDateString(dateLocale);
300 }
301
302 type ProjectDropPosition = "before" | "after";
303 type WorkbenchHeaderMenu = "more" | "add" | null;
304 type WorkbenchOrganizeMode = "project" | "recent" | "time";
305 type WorkbenchSortMode = "created" | "updated";
306
307 type CollapseSnapshot = {
308 expanded: Set<string>;
309 manuallyCollapsed: Set<string>;
310 };
311
312 type PinnedTreeSections = {
313 pinned: ProjectNode[];
314 projects: ProjectNode[];
315 };
316
317 const GLOBAL_PROJECT_ORDER_KEY = "__global__";
318 const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
319 // Shared by classic and workbench; key string kept for existing saved choices.
320 const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
321 const READ_ACTIVITY_KEY = "projectTree:readActivity";
322 const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
323
324 function loadReadActivity(): ProjectTreeReadActivity {
325 try {
326 const raw = localStorage.getItem(READ_ACTIVITY_KEY);
327 if (!raw) return {};
328 const parsed = JSON.parse(raw) as Record<string, unknown>;
329 const out: ProjectTreeReadActivity = {};
330 for (const [key, value] of Object.entries(parsed)) {
331 if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
332 }
333 return out;
334 } catch {
335 return {};
336 }
337 }
338
339 function saveReadActivity(readActivity: ProjectTreeReadActivity) {
340 try {
341 localStorage.setItem(READ_ACTIVITY_KEY, JSON.stringify(readActivity));
342 } catch {
343 /* localStorage unavailable */
344 }
345 }
346
347 function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
348 try {
349 const value = localStorage.getItem(WORKBENCH_ORGANIZE_KEY);
350 if (value === "recent" || value === "time") return value;
351 } catch {
352 /* localStorage unavailable */
353 }
354 return "project";
355 }
356
357 function loadWorkbenchSortMode(): WorkbenchSortMode {
358 try {
359 const value = localStorage.getItem(WORKBENCH_SORT_KEY);
360 if (value === "created") return "created";
361 } catch {
362 /* localStorage unavailable */
363 }
364 return "updated";
365 }
366
367 function projectOrderKey(node: ProjectNode): string {
368 if (node.kind === "global_folder") return GLOBAL_PROJECT_ORDER_KEY;
369 if (node.kind === "project" && node.root) return node.root;
370 return "";
371 }
372
373 function projectRoots(nodes: ProjectNode[]): string[] {
374 return nodes
375 .map(projectOrderKey)
376 .filter((key) => key !== "");
377 }
378
379 function collapsibleFolderKeys(nodes: ProjectNode[], depth = 0): string[] {
380 const keys: string[] = [];
381 for (const node of nodes) {
382 if (!node) continue;
383 const children = asArray(node.children);
384 if ((node.kind === "project" || node.kind === "global_folder") && children.length > 0) {
385 keys.push(projectNodeKey(node, depth));
386 }
387 keys.push(...collapsibleFolderKeys(children, depth + 1));
388 }
389 return keys;
390 }
391
392 export function activeSessionAncestorKeys(
393 nodes: ProjectNode[],
394 activeScope?: string,
395 activeWorkspaceRoot?: string,
396 activeTopicId?: string,
397 activeSessionPath?: string,
398 ): string[] {
399 const walk = (nodeList: ProjectNode[], ancestors: string[]): string[] | null => {
400 for (const node of nodeList) {
401 if (!node) continue;
402 if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return ancestors;
403 const children = asArray(node.children);
404 if (children.length > 0) {
405 const next = walk(children, [...ancestors, projectNodeKey(node, ancestors.length)]);
406 if (next) return next;
407 }
408 }
409 return null;
410 };
411 return walk(nodes, []) ?? [];
412 }
413
414 export function defaultExpandedProjectTreeKeys(
415 nodes: ProjectNode[],
416 activeScope?: string,
417 activeWorkspaceRoot?: string,
418 activeTopicId?: string,
419 activeSessionPath?: string,
420 ): string[] {
421 return activeSessionAncestorKeys(nodes, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
422 }
423
424 function reorderedProjectRoots(nodes: ProjectNode[], draggedRoot: string, targetRoot: string, position: ProjectDropPosition): string[] {
425 const roots = projectRoots(nodes);
426 if (draggedRoot === targetRoot || !roots.includes(draggedRoot) || !roots.includes(targetRoot)) return roots;
427 const next = roots.filter((root) => root !== draggedRoot);
428 const targetIndex = next.indexOf(targetRoot);
429 if (targetIndex < 0) return roots;
430 next.splice(position === "before" ? targetIndex : targetIndex + 1, 0, draggedRoot);
431 return next;
432 }
433
434 function applyProjectOrder(nodes: ProjectNode[], roots: string[]): ProjectNode[] {
435 const projectEntries = nodes
436 .map((node): [string, ProjectNode] => [projectOrderKey(node), node])
437 .filter(([key]) => key !== "");
438 const byRoot = new Map<string, ProjectNode>(projectEntries);
439 const orderedProjects = roots.map((root) => byRoot.get(root)).filter((node): node is ProjectNode => Boolean(node));
440 const orderedKeys = new Set(roots);
441 const nonProjects = nodes.filter((node) => !orderedKeys.has(projectOrderKey(node)));
442 return [...nonProjects, ...orderedProjects];
443 }
444
445 function topicSortValue(node: ProjectNode, sortMode: WorkbenchSortMode): number {
446 if (sortMode === "created") return node.createdAt || node.lastActivityAt || 0;
447 return topicActivityTime(node);
448 }
449
450 function projectSortValue(node: ProjectNode, sortMode: WorkbenchSortMode): number {
451 return asArray(node.children).reduce((max, child) => {
452 if (!isTopicNode(child)) return max;
453 return Math.max(max, topicSortValue(child, sortMode));
454 }, 0);
455 }
456
457 function sortWorkbenchChildren(children: ProjectNode[], sortMode: WorkbenchSortMode): ProjectNode[] {
458 return [...children].sort((a, b) => {
459 if (!isTopicNode(a) || !isTopicNode(b)) return 0;
460 if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1;
461 return topicSortValue(b, sortMode) - topicSortValue(a, sortMode);
462 });
463 }
464
465 function arrangeWorkbenchTree(nodes: ProjectNode[], organizeMode: WorkbenchOrganizeMode, sortMode: WorkbenchSortMode): ProjectNode[] {
466 const arranged = nodes.map((node) => {
467 if (node.kind !== "project" && node.kind !== "global_folder") return node;
468 return { ...node, children: sortWorkbenchChildren(asArray(node.children), sortMode) };
469 });
470 if (organizeMode === "project") return arranged;
471 const mode = organizeMode === "recent" ? "updated" : sortMode;
472 return [...arranged].sort((a, b) => {
473 if (Boolean(a.pinned) !== Boolean(b.pinned)) return a.pinned ? -1 : 1;
474 return projectSortValue(b, mode) - projectSortValue(a, mode);
475 });
476 }
477
478 // Classic keeps the user's manual project order but sorts topics inside each
479 // folder, so row order matches the activity time shown in the meta line
480 // instead of the persisted insertion order.
481 export function arrangeClassicProjectTree(nodes: ProjectNode[], sortMode: WorkbenchSortMode): ProjectNode[] {
482 return arrangeWorkbenchTree(nodes, "project", sortMode);
483 }
484
485 // Classic folders preview only the first few topics; the rest sit behind a
486 // show-more toggle so one busy project cannot push the others out of view.
487 export const CLASSIC_TOPIC_PREVIEW_LIMIT = 5;
488
489 export function classicTopicWindow(children: ProjectNode[], showAll: boolean): { visible: ProjectNode[]; hiddenCount: number } {
490 if (showAll || children.length <= CLASSIC_TOPIC_PREVIEW_LIMIT) return { visible: children, hiddenCount: 0 };
491 return {
492 visible: children.slice(0, CLASSIC_TOPIC_PREVIEW_LIMIT),
493 hiddenCount: children.length - CLASSIC_TOPIC_PREVIEW_LIMIT,
494 };
495 }
496
497 export function splitPinnedProjectTree(
498 nodes: ProjectNode[],
499 sortMode: WorkbenchSortMode,
500 includePinnedProjects = true,
501 ): PinnedTreeSections {
502 const pinnedTopics: ProjectNode[] = [];
503 const pinnedProjects: ProjectNode[] = [];
504 const projects: ProjectNode[] = [];
505
506 for (const node of nodes) {
507 if (!node) continue;
508 const isFolder = node.kind === "project" || node.kind === "global_folder";
509 if (!isFolder) {
510 if (node.pinned) pinnedTopics.push(node);
511 else projects.push(node);
512 continue;
513 }
514
515 if (includePinnedProjects && node.pinned && node.kind === "project") {
516 pinnedProjects.push(node);
517 continue;
518 }
519
520 const children = asArray(node.children);
521 const nextChildren: ProjectNode[] = [];
522 for (const child of children) {
523 if (isTopicNode(child) && child.pinned) {
524 pinnedTopics.push(child);
525 continue;
526 }
527 nextChildren.push(child);
528 }
529 projects.push({ ...node, children: nextChildren });
530 }
531
532 pinnedTopics.sort((a, b) => topicSortValue(b, sortMode) - topicSortValue(a, sortMode));
533 pinnedProjects.sort((a, b) => projectSortValue(b, sortMode) - projectSortValue(a, sortMode));
534
535 return {
536 pinned: [...pinnedTopics, ...pinnedProjects],
537 projects,
538 };
539 }
540
541 // Global rows use the same project tree recipe; the fallback supplies their non-workspace accent.
542 function projectAccentStyle(color?: string, fallbackValue?: string): CSSProperties | undefined {
543 const value = projectColorValue(color) || fallbackValue;
544 if (!value) return undefined;
545 return { "--project-accent": value } as CSSProperties;
546 }
547
548 function colorMenuLabel(label: string, color?: string, active = false) {
549 const value = projectColorValue(color);
550 return (
551 <span className="project-tree__color-option">
552 <span
553 className="project-tree__color-swatch"
554 style={value ? ({ "--project-accent": value } as CSSProperties) : undefined}
555 aria-hidden="true"
556 />
557 <span>{label}</span>
558 {active && <Check className="project-tree__color-check" size={12} />}
559 </span>
560 );
561 }
562
563 function menuLabelWithCheck(label: string, checked: boolean) {
564 return (
565 <span className="context-menu__label-with-check">
566 <span className="context-menu__label-text">{label}</span>
567 {checked && <Check className="context-menu__check" size={13} aria-hidden="true" />}
568 </span>
569 );
570 }
571
572 function revealLabelKey(platform: string): "projectTree.revealInFinder" | "projectTree.revealInExplorer" | "projectTree.revealInFileManager" {
573 if (platform === "darwin") return "projectTree.revealInFinder";
574 if (platform === "windows") return "projectTree.revealInExplorer";
575 return "projectTree.revealInFileManager";
576 }
577
578 function projectColorLabel(t: Translator, color?: string): string {
579 switch (color) {
580 case "red": return t("projectTree.colorRed");
581 case "orange": return t("projectTree.colorOrange");
582 case "amber": return t("projectTree.colorAmber");
583 case "green": return t("projectTree.colorGreen");
584 case "teal": return t("projectTree.colorTeal");
585 case "blue": return t("projectTree.colorBlue");
586 case "purple": return t("projectTree.colorPurple");
587 case "pink": return t("projectTree.colorPink");
588 default: return t("projectTree.colorDefault");
589 }
590 }
591
592 export function ProjectTree({
593 activeScope,
594 activeWorkspaceRoot,
595 activeTopicId,
596 activeSessionPath,
597 imTopicSources = {},
598 variant = "classic",
599 onOpenTopic,
600 onAddProject,
601 onCreateTopic,
602 onCreateDeliveryWorktree,
603 onRenameTopic,
604 onTopicsChanged,
605 refreshSignal,
606 timeFilter,
607 onTimeFilterChange,
608 searchExpanded = true,
609 searchFocusSignal = 0,
610 showShortcutBadges = false,
611 shortcutPlatform,
612 onVisibleTopicsChange,
613 }: ProjectTreeProps) {
614 const t = useT();
615 const { showToast } = useToast();
616 const compactTopics = variant === "workbench";
617 const creationTopics = variant === "creation";
618 const [tree, setTree] = useState<ProjectNode[]>([]);
619 const [expanded, setExpanded] = useState<Set<string>>(new Set());
620 const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
621 const [creatingProject, setCreatingProject] = useState<string | null>(null);
622 const [query, setQuery] = useState("");
623 const [editingTopic, setEditingTopic] = useState<string | null>(null);
624 const [topicDraft, setTopicDraft] = useState("");
625 const [menuTopic, setMenuTopic] = useState<string | null>(null);
626 const [menuProject, setMenuProject] = useState<{ key: string; root: string; path: string; scope: "global" | "project"; label: string } | null>(null);
627 const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
628 const [editingProject, setEditingProject] = useState<{ key: string; root: string } | null>(null);
629 const [projectDraft, setProjectDraft] = useState("");
630 const [addingProject, setAddingProject] = useState(false);
631 const [isolatingProject, setIsolatingProject] = useState<string | null>(null);
632 const [worktreeAvailability, setWorktreeAvailability] = useState<Record<string, { available: boolean; reason?: string }>>({});
633 const [confirmAction, setConfirmAction] = useState<{ topicId: string; action: "trash" } | null>(null);
634 const [confirmRemoveProject, setConfirmRemoveProject] = useState<string | null>(null);
635 const [dragProjectRoot, setDragProjectRoot] = useState<string | null>(null);
636 const [dropProject, setDropProject] = useState<{ root: string; position: ProjectDropPosition } | null>(null);
637 const [collapseSnapshot, setCollapseSnapshot] = useState<CollapseSnapshot | null>(null);
638 const [platform, setPlatform] = useState("");
639 const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
640 const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
641 const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
642 const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
643 const filterRef = useRef<HTMLDivElement>(null);
644 const filterTriggerRef = useRef<HTMLButtonElement>(null);
645 const searchInputRef = useRef<HTMLInputElement>(null);
646 const topicIndexRef = useRef(0);
647 const visibleTopicsCollectorRef = useRef<TopicShortcutEntry[]>([]);
648 const [filterMenuOpen, setFilterMenuOpen] = useState(false);
649 const [showAllTopics, setShowAllTopics] = useState<Set<string>>(new Set());
650 const [hoverCard, setHoverCard] = useState<{ key: string; card: ProjectTreeTopicHoverCard; left: number; top: number } | null>(null);
651 const hoverCardTimerRef = useRef<number | null>(null);
652 const creatingRef = useRef(false);
653 const trashingRef = useRef(false);
654 const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
655 useEffect(() => {
656 return () => {
657 if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current.timer);
658 if (hoverCardTimerRef.current !== null) window.clearTimeout(hoverCardTimerRef.current);
659 };
660 }, []);
661 const manuallyCollapsedRef = useRef(manuallyCollapsed);
662
663 const cancelHoverCard = useCallback(() => {
664 if (hoverCardTimerRef.current !== null) {
665 window.clearTimeout(hoverCardTimerRef.current);
666 hoverCardTimerRef.current = null;
667 }
668 setHoverCard((current) => (current === null ? current : null));
669 }, []);
670
671 const toggleShowAllTopics = useCallback((key: string) => {
672 setShowAllTopics((prev) => {
673 const next = new Set(prev);
674 if (next.has(key)) next.delete(key);
675 else next.add(key);
676 return next;
677 });
678 }, []);
679
680 const closeMenu = useCallback(() => {
681 setMenuTopic(null);
682 setMenuProject(null);
683 setMenuPoint(null);
684 setConfirmAction(null);
685 setConfirmRemoveProject(null);
686 setWorkbenchHeaderMenu(null);
687 }, []);
688
689 const updateManuallyCollapsed = useCallback((updater: (prev: Set<string>) => Set<string>) => {
690 setManuallyCollapsed((prev) => {
691 const next = updater(prev);
692 manuallyCollapsedRef.current = next;
693 return next;
694 });
695 }, []);
696
697 const refresh = useCallback(async () => {
698 try {
699 const nodes = await app.ListProjectTree();
700 const list = asArray(nodes);
701 setTree(list);
702 setExpanded((prev) => {
703 const next = new Set(prev);
704 const collapsed = manuallyCollapsedRef.current;
705 for (const key of defaultExpandedProjectTreeKeys(list, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) {
706 if (!collapsed.has(key)) next.add(key);
707 }
708 return next;
709 });
710 } catch {
711 /* bridge unavailable */
712 }
713 }, [activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath]);
714
715 useEffect(() => {
716 manuallyCollapsedRef.current = manuallyCollapsed;
717 }, [manuallyCollapsed]);
718
719 const searchVisible = searchExpanded || query.trim().length > 0;
720
721 useEffect(() => {
722 if (!searchVisible || searchFocusSignal <= 0) return;
723 searchInputRef.current?.focus();
724 }, [searchFocusSignal, searchVisible]);
725
726 useEffect(() => {
727 void refresh();
728 }, [refresh, refreshSignal]);
729
730 const markNodeRead = useCallback((node: ProjectNode) => {
731 const key = projectTreeReadActivityKey(node);
732 const activityAt = topicActivityAt(node);
733 if (!key || activityAt <= 0) return;
734 setReadActivity((prev) => {
735 if ((prev[key] ?? 0) >= activityAt) return prev;
736 const next = { ...prev, [key]: activityAt };
737 saveReadActivity(next);
738 return next;
739 });
740 }, []);
741
742 useEffect(() => {
743 if (tree.length === 0) return;
744 try {
745 if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
746 } catch {
747 return;
748 }
749 const baseline: ProjectTreeReadActivity = {};
750 const collectBaseline = (nodes: ProjectNode[]) => {
751 for (const node of nodes) {
752 if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
753 const key = projectTreeReadActivityKey(node);
754 const activityAt = topicActivityAt(node);
755 if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
756 }
757 collectBaseline(asArray(node.children));
758 }
759 };
760 collectBaseline(tree);
761 try {
762 localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
763 } catch {
764 /* localStorage unavailable */
765 }
766 if (Object.keys(baseline).length === 0) return;
767 setReadActivity((prev) => {
768 const next = { ...prev };
769 let changed = false;
770 for (const [key, value] of Object.entries(baseline)) {
771 if ((next[key] ?? 0) >= value) continue;
772 next[key] = value;
773 changed = true;
774 }
775 if (!changed) return prev;
776 saveReadActivity(next);
777 return next;
778 });
779 }, [tree]);
780
781 useEffect(() => {
782 const markActive = (nodes: ProjectNode[]) => {
783 for (const node of nodes) {
784 if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
785 markActive(asArray(node.children));
786 }
787 };
788 markActive(tree);
789 }, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, tree]);
790
791 useEffect(() => {
792 try {
793 localStorage.setItem(WORKBENCH_ORGANIZE_KEY, workbenchOrganizeMode);
794 } catch {
795 /* ignore */
796 }
797 }, [workbenchOrganizeMode]);
798
799 useEffect(() => {
800 try {
801 localStorage.setItem(WORKBENCH_SORT_KEY, workbenchSortMode);
802 } catch {
803 /* ignore */
804 }
805 }, [workbenchSortMode]);
806
807 useEffect(() => {
808 let cancelled = false;
809 void app.Platform().then((value) => {
810 if (!cancelled) setPlatform(value);
811 }).catch(() => {});
812 return () => {
813 cancelled = true;
814 };
815 }, []);
816
817 // Close the time-filter menu on outside click or Escape; move focus into the
818 // menu on open and back to the trigger on Escape so it is keyboard-operable.
819 useEffect(() => {
820 if (!filterMenuOpen) return;
821 const onMouseDown = (e: MouseEvent) => {
822 if (filterRef.current && !filterRef.current.contains(e.target as Node)) setFilterMenuOpen(false);
823 };
824 const onKeyDown = (e: KeyboardEvent) => {
825 if (e.key === "Escape") {
826 setFilterMenuOpen(false);
827 filterTriggerRef.current?.focus();
828 }
829 };
830 document.addEventListener("mousedown", onMouseDown);
831 document.addEventListener("keydown", onKeyDown);
832 const menu = filterRef.current?.querySelector<HTMLElement>(".project-tree__time-filter-menu");
833 (menu?.querySelector<HTMLButtonElement>(".project-tree__time-filter-opt--on") ??
834 menu?.querySelector<HTMLButtonElement>('[role="menuitem"]'))?.focus();
835 return () => {
836 document.removeEventListener("mousedown", onMouseDown);
837 document.removeEventListener("keydown", onKeyDown);
838 };
839 }, [filterMenuOpen]);
840
841 const moveMenuFocus = (e: ReactKeyboardEvent<HTMLDivElement>) => {
842 if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return;
843 e.preventDefault();
844 const items = Array.from(e.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'));
845 if (items.length === 0) return;
846 const current = items.indexOf(document.activeElement as HTMLButtonElement);
847 const next = e.key === "Home" ? 0
848 : e.key === "End" ? items.length - 1
849 : e.key === "ArrowDown" ? (current + 1 + items.length) % items.length
850 : (current - 1 + items.length) % items.length;
851 items[next]?.focus();
852 };
853
854 const toggleExpand = (key: string) => {
855 const willCollapse = expanded.has(key);
856 setExpanded((prev) => {
857 const next = new Set(prev);
858 if (next.has(key)) next.delete(key);
859 else next.add(key);
860 return next;
861 });
862 updateManuallyCollapsed((prev) => {
863 const next = new Set(prev);
864 if (willCollapse) next.add(key);
865 else next.delete(key);
866 return next;
867 });
868 };
869
870 const folderKeys = useMemo(() => collapsibleFolderKeys(tree), [tree]);
871 const searchActive = query.trim().length > 0;
872 const hasExpandedFolders = !searchActive && folderKeys.some((key) => expanded.has(key));
873 const canRestoreCollapsedView = collapseSnapshot !== null;
874 const canToggleCollapsedView = !searchActive && folderKeys.length > 0 && (hasExpandedFolders || canRestoreCollapsedView);
875 const collapseToggleLabel = t(canRestoreCollapsedView ? "projectTree.restoreCollapsedTooltip" : "projectTree.collapseAllTooltip");
876 const workbenchCollapseToggleLabel = t(canRestoreCollapsedView ? "projectTree.restoreCollapsedWorkbench" : "projectTree.collapseAllWorkbench");
877
878 const toggleCollapsedView = useCallback(() => {
879 if (searchActive || folderKeys.length === 0) return;
880 if (collapseSnapshot) {
881 const currentFolderKeys = new Set(folderKeys);
882 setExpanded(() => {
883 const next = new Set<string>();
884 for (const key of collapseSnapshot.expanded) {
885 if (currentFolderKeys.has(key)) next.add(key);
886 }
887 return next;
888 });
889 updateManuallyCollapsed(() => {
890 const next = new Set<string>();
891 for (const key of collapseSnapshot.manuallyCollapsed) {
892 if (currentFolderKeys.has(key)) next.add(key);
893 }
894 return next;
895 });
896 setCollapseSnapshot(null);
897 return;
898 }
899 if (!hasExpandedFolders) return;
900 setCollapseSnapshot({
901 expanded: new Set(expanded),
902 manuallyCollapsed: new Set(manuallyCollapsed),
903 });
904 setExpanded((prev) => {
905 let changed = false;
906 const next = new Set(prev);
907 for (const key of folderKeys) {
908 if (next.delete(key)) changed = true;
909 }
910 return changed ? next : prev;
911 });
912 updateManuallyCollapsed((prev) => {
913 let changed = false;
914 const next = new Set(prev);
915 for (const key of folderKeys) {
916 if (!next.has(key)) {
917 next.add(key);
918 changed = true;
919 }
920 }
921 return changed ? next : prev;
922 });
923 }, [collapseSnapshot, expanded, folderKeys, hasExpandedFolders, manuallyCollapsed, searchActive, updateManuallyCollapsed]);
924
925 const handleAddProject = async () => {
926 if (addingProject) return;
927 setAddingProject(true);
928 try {
929 await onAddProject();
930 await refresh();
931 } finally {
932 setAddingProject(false);
933 }
934 };
935
936 const openWorkbenchHeaderMenu = (
937 event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
938 menu: Exclude<WorkbenchHeaderMenu, null>,
939 ) => {
940 event.preventDefault();
941 event.stopPropagation();
942 setMenuTopic(null);
943 setMenuProject(null);
944 setConfirmAction(null);
945 setConfirmRemoveProject(null);
946 setFilterMenuOpen(false);
947 setMenuPoint(contextMenuPointFromEvent(event));
948 setWorkbenchHeaderMenu((value) => (value === menu ? null : menu));
949 };
950
951 const handleCreateTopic = async (scope: string, workspaceRoot: string, key: string) => {
952 if (creatingRef.current) return;
953 creatingRef.current = true;
954 setCreatingProject(key);
955 setMenuProject(null);
956 setMenuPoint(null);
957 setExpanded((prev) => {
958 const next = new Set(prev);
959 next.add(key);
960 return next;
961 });
962 updateManuallyCollapsed((prev) => {
963 if (!prev.has(key)) return prev;
964 const next = new Set(prev);
965 next.delete(key);
966 return next;
967 });
968 try {
969 if (onCreateTopic) {
970 await onCreateTopic(scope, workspaceRoot);
971 await refresh();
972 await onTopicsChanged?.();
973 return;
974 }
975 const topic = await app.CreateTopic(scope, workspaceRoot, "");
976 await refresh();
977 await onTopicsChanged?.();
978 await onOpenTopic(scope, workspaceRoot, topic.id);
979 } catch {
980 /* ignore */
981 } finally {
982 creatingRef.current = false;
983 setCreatingProject(null);
984 }
985 };
986
987 const handleCreateDeliveryWorktree = async (workspaceRoot: string) => {
988 if (!workspaceRoot || isolatingProject) return;
989 setIsolatingProject(workspaceRoot);
990 closeMenu();
991 try {
992 await onCreateDeliveryWorktree?.(workspaceRoot);
993 } catch (err) {
994 showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 });
995 } finally {
996 setIsolatingProject(null);
997 }
998 };
999
1000 const startRenameTopic = (node: ProjectNode, label: string) => {
1001 setMenuTopic(null);
1002 setMenuProject(null);
1003 setMenuPoint(null);
1004 setConfirmAction(null);
1005 setEditingTopic(node.topicId ?? null);
1006 setTopicDraft(label);
1007 };
1008
1009 const startRenameProject = (key: string, root: string, label: string) => {
1010 setMenuProject(null);
1011 setMenuTopic(null);
1012 setMenuPoint(null);
1013 setConfirmRemoveProject(null);
1014 setEditingProject({ key, root });
1015 setProjectDraft(label);
1016 };
1017
1018 const commitRenameTopic = async (topicId: string) => {
1019 const title = topicDraft.trim();
1020 setEditingTopic(null);
1021 if (!title) return;
1022 try {
1023 if (onRenameTopic) await onRenameTopic(topicId, title);
1024 else await app.RenameTopic(topicId, title);
1025 await refresh();
1026 if (!onRenameTopic) await onTopicsChanged?.();
1027 } catch (err) {
1028 showToast(err instanceof Error ? err.message : String(err), "error");
1029 }
1030 };
1031
1032 const commitRenameProject = async (root: string) => {
1033 const title = projectDraft.trim();
1034 setEditingProject(null);
1035 if (!title) return;
1036 try {
1037 await app.RenameProject(root, title);
1038 await refresh();
1039 } catch (err) {
1040 showToast(err instanceof Error ? err.message : String(err), "error");
1041 }
1042 };
1043
1044 const trashTopic = async (topicId: string) => {
1045 if (trashingRef.current) return;
1046 trashingRef.current = true;
1047 try {
1048 await app.TrashTopic(topicId);
1049 setMenuTopic(null);
1050 setMenuPoint(null);
1051 setConfirmAction(null);
1052 await refresh();
1053 await onTopicsChanged?.();
1054 } catch (err) {
1055 showToast(err instanceof Error ? err.message : String(err), "error");
1056 } finally {
1057 trashingRef.current = false;
1058 }
1059 };
1060
1061 const setTopicPinned = async (topicId: string, pinned: boolean) => {
1062 try {
1063 await app.SetTopicPinned(topicId, pinned);
1064 setMenuTopic(null);
1065 setMenuPoint(null);
1066 await refresh();
1067 await onTopicsChanged?.();
1068 } catch (err) {
1069 showToast(err instanceof Error ? err.message : String(err), "error");
1070 }
1071 };
1072
1073 const setProjectPinned = async (workspaceRoot: string, pinned: boolean) => {
1074 if (!workspaceRoot) return;
1075 try {
1076 await app.SetProjectPinned(workspaceRoot, pinned);
1077 setMenuProject(null);
1078 setMenuPoint(null);
1079 await refresh();
1080 await onTopicsChanged?.();
1081 } catch (err) {
1082 showToast(err instanceof Error ? err.message : String(err), "error");
1083 }
1084 };
1085
1086 const copyProjectPath = async (path: string) => {
1087 if (!path) return;
1088 try {
1089 await navigator.clipboard?.writeText(path);
1090 } catch {
1091 /* ignore */
1092 }
1093 };
1094
1095 const removeProject = async (path: string) => {
1096 if (!path) return;
1097 try {
1098 await app.RemoveWorkspace(path);
1099 setMenuProject(null);
1100 setMenuPoint(null);
1101 setConfirmRemoveProject(null);
1102 await refresh();
1103 } catch (err) {
1104 showToast(err instanceof Error ? err.message : String(err), "error");
1105 }
1106 };
1107
1108 const setProjectColor = async (path: string, color: string) => {
1109 try {
1110 await app.SetProjectColor(path, color);
1111 setMenuProject(null);
1112 setMenuPoint(null);
1113 await refresh();
1114 await onTopicsChanged?.();
1115 } catch {
1116 /* ignore */
1117 }
1118 };
1119
1120 const visibleTree = useMemo(() => {
1121 const q = query.trim().toLowerCase();
1122 // Time filter: compute cutoff timestamp.
1123 const diff = timeFilter === "1h" ? 60 * 60 * 1000
1124 : timeFilter === "3h" ? 3 * 60 * 60 * 1000
1125 : timeFilter === "5h" ? 5 * 60 * 60 * 1000
1126 : timeFilter === "1d" ? 24 * 60 * 60 * 1000
1127 : 0;
1128 const nthLatestActivity = (n: number): number | null => {
1129 const times = new Set<number>();
1130 const collect = (nodes: ProjectNode[]) => {
1131 for (const node of nodes) {
1132 if (node.kind === "topic" || node.kind === "global_topic") times.add(topicActivityTime(node));
1133 collect(asArray(node.children));
1134 }
1135 };
1136 collect(tree);
1137 const sorted = [...times].sort((a, b) => b - a);
1138 return sorted.length === 0 ? null : sorted[Math.min(n, sorted.length) - 1];
1139 };
1140 const cutoff: number | null = timeFilter === "all" ? null
1141 : timeFilter === "10" ? nthLatestActivity(10)
1142 : timeFilter === "20" ? nthLatestActivity(20)
1143 : Date.now() - diff;
1144 const topicMatchesTime = (node: ProjectNode) => {
1145 if (cutoff === null) return true;
1146 return topicActivityTime(node) >= cutoff;
1147 };
1148 const matchesQuery = (node: ProjectNode) =>
1149 [node.label, node.root, node.topicId].some((value) => (value ?? "").toLowerCase().includes(q));
1150 const filterNode = (node: ProjectNode): ProjectNode | null => {
1151 // For folder nodes: always show when time filter is active (so the tree structure remains navigable).
1152 const isFolder = node.kind === "project" || node.kind === "global_folder";
1153 const children = asArray(node.children)
1154 .map(filterNode)
1155 .filter((child): child is ProjectNode => child !== null);
1156 if (isFolder) {
1157 if (cutoff !== null && children.length === 0 && !matchesQuery(node) && q === "") return null;
1158 if (children.length > 0 || matchesQuery(node)) return { ...node, children };
1159 if (q) return null;
1160 // With only time filter, show folder if it has any child that matches the time.
1161 const hasTimeMatch = asArray(node.children).some((c) => topicMatchesTime(c));
1162 return hasTimeMatch ? { ...node, children: asArray(node.children).filter(topicMatchesTime) } : null;
1163 }
1164 if (!q && cutoff === null) return node;
1165 if (cutoff !== null && !topicMatchesTime(node)) return null;
1166 if (q && !matchesQuery(node)) return null;
1167 return node;
1168 };
1169 const filtered = tree
1170 .map(filterNode)
1171 .filter((node): node is ProjectNode => node !== null);
1172 if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode);
1173 if (creationTopics) return arrangeWorkbenchTree(filtered, "project", "updated");
1174 return arrangeClassicProjectTree(filtered, workbenchSortMode);
1175 }, [compactTopics, creationTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
1176
1177 const pinnedTreeSections = useMemo<PinnedTreeSections>(() => {
1178 if (creationTopics) return { pinned: [], projects: visibleTree };
1179 return splitPinnedProjectTree(visibleTree, workbenchSortMode, compactTopics);
1180 }, [compactTopics, creationTopics, visibleTree, workbenchSortMode]);
1181
1182 const classicTopics = !compactTopics && !creationTopics;
1183 const classicTruncationActive = classicTopics && query.trim() === "" && timeFilter === "all";
1184
1185 const projectLabelByRoot = useMemo(() => {
1186 const map = new Map<string, string>();
1187 for (const nodeItem of tree) {
1188 if (!nodeItem) continue;
1189 if (nodeItem.kind === "project" && nodeItem.root) map.set(nodeItem.root, nodeItem.label || nodeItem.root);
1190 if (nodeItem.kind === "global_folder") map.set(GLOBAL_PROJECT_ORDER_KEY, nodeItem.label || "Global");
1191 }
1192 return map;
1193 }, [tree]);
1194
1195 const scheduleHoverCard = useCallback((element: HTMLElement, rowKey: string, node: ProjectNode) => {
1196 if (hoverCardTimerRef.current !== null) window.clearTimeout(hoverCardTimerRef.current);
1197 hoverCardTimerRef.current = window.setTimeout(() => {
1198 hoverCardTimerRef.current = null;
1199 if (!element.isConnected) return;
1200 if (menuTopic || menuProject || editingTopic || editingProject || dragProjectRoot) return;
1201 const rect = element.getBoundingClientRect();
1202 const globalScope = node.kind === "global_topic" || node.kind === "global_session";
1203 const projectLabel = globalScope
1204 ? projectLabelByRoot.get(GLOBAL_PROJECT_ORDER_KEY) ?? "Global"
1205 : projectLabelByRoot.get(node.root ?? "") ?? "";
1206 setHoverCard({
1207 key: rowKey,
1208 card: projectTreeTopicHoverCardModel(node, t, projectLabel),
1209 left: rect.right + 10,
1210 top: Math.max(8, Math.min(rect.top, window.innerHeight - 150)),
1211 });
1212 }, 350);
1213 }, [menuTopic, menuProject, editingTopic, editingProject, dragProjectRoot, projectLabelByRoot, t]);
1214
1215 // Opening an old session from history can land on a topic hidden behind the
1216 // classic show-more window; reveal that folder so the active row stays visible.
1217 useEffect(() => {
1218 if (!classicTruncationActive) return;
1219 const revealKeys: string[] = [];
1220 for (const nodeItem of visibleTree) {
1221 if (!nodeItem || (nodeItem.kind !== "project" && nodeItem.kind !== "global_folder")) continue;
1222 const children = asArray(nodeItem.children);
1223 const activeIndex = children.findIndex((child) =>
1224 topicIsActive(child, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath) ||
1225 asArray(child.children).some((grand) => topicIsActive(grand, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)));
1226 if (activeIndex >= CLASSIC_TOPIC_PREVIEW_LIMIT) revealKeys.push(projectNodeKey(nodeItem, 0));
1227 }
1228 if (revealKeys.length === 0) return;
1229 setShowAllTopics((prev) => {
1230 if (revealKeys.every((key) => prev.has(key))) return prev;
1231 const next = new Set(prev);
1232 for (const key of revealKeys) next.add(key);
1233 return next;
1234 });
1235 }, [classicTruncationActive, visibleTree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath]);
1236
1237 const projectDragEnabled = query.trim() === "";
1238
1239 const commitProjectReorder = useCallback(async (draggedRoot: string, targetRoot: string, position: ProjectDropPosition) => {
1240 const nextRoots = reorderedProjectRoots(tree, draggedRoot, targetRoot, position);
1241 const currentRoots = projectRoots(tree);
1242 if (nextRoots.join("\n") === currentRoots.join("\n")) return;
1243 setTree((current) => applyProjectOrder(current, nextRoots));
1244 try {
1245 await app.ReorderProjects(nextRoots);
1246 await refresh();
1247 await onTopicsChanged?.();
1248 } catch {
1249 await refresh();
1250 }
1251 }, [onTopicsChanged, refresh, tree]);
1252
1253 const clearProjectDrag = useCallback(() => {
1254 setDragProjectRoot(null);
1255 setDropProject(null);
1256 }, []);
1257
1258 useEffect(() => {
1259 if (!dragProjectRoot) return;
1260 window.addEventListener("dragend", clearProjectDrag);
1261 window.addEventListener("drop", clearProjectDrag);
1262 window.addEventListener("blur", clearProjectDrag);
1263 return () => {
1264 window.removeEventListener("dragend", clearProjectDrag);
1265 window.removeEventListener("drop", clearProjectDrag);
1266 window.removeEventListener("blur", clearProjectDrag);
1267 };
1268 }, [clearProjectDrag, dragProjectRoot]);
1269
1270 const activeAncestorKeys = useMemo(
1271 () => activeSessionAncestorKeys(tree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath),
1272 [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, tree],
1273 );
1274
1275 useEffect(() => {
1276 if (activeAncestorKeys.length === 0) return;
1277 setExpanded((prev) => {
1278 let changed = false;
1279 const next = new Set(prev);
1280 for (const key of activeAncestorKeys) {
1281 if (manuallyCollapsed.has(key) || next.has(key)) continue;
1282 next.add(key);
1283 changed = true;
1284 }
1285 return changed ? next : prev;
1286 });
1287 }, [activeAncestorKeys, manuallyCollapsed]);
1288
1289 const renderNode = (node: ProjectNode | null | undefined, depth: number, section: "pinned" | "projects" = "projects", isVisible = true) => {
1290 if (!node) return null;
1291 const key = projectNodeKey(node, depth);
1292 const children = asArray(node.children);
1293 const isExpanded = query.trim() ? true : expanded.has(key);
1294 const hasChildren = children.length > 0;
1295 const folderDisclosure = projectTreeFolderDisclosure(hasChildren, isExpanded, classicTopics);
1296
1297 if (isTopicNode(node) || isRuntimeSessionNode(node)) {
1298 const isSessionNode = isRuntimeSessionNode(node);
1299 const openRequest = projectTreeTopicOpenRequest(node);
1300 const scope = openRequest?.scope ?? "project";
1301 const scopeClass = scope === "global" ? " project-tree__topic--global" : " project-tree__topic--project";
1302 const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
1303 const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
1304 const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
1305 const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
1306 const activityAt = node.lastActivityAt || node.createdAt || 0;
1307 // Every variant is a single-line row with the activity time on the right;
1308 // classic moved there too so turns and the exact date live in the hover
1309 // preview card and the accessible label instead of a second meta line.
1310 const sideTimeVisible = true;
1311 const timeLabel = activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t);
1312 const exactTimeLabel = activityAt ? topicActivityDateLabel(activityAt) : "";
1313 const metaFull = projectTreeTopicMetaLine(node, t, compactTopics);
1314 const status = topicStatus(node);
1315 const statusLabel = topicStatusLabel(node, t);
1316 const archiveBlocked = projectTreeTopicArchiveBlocked(node);
1317 const waitingConfirmation = status === "waiting_confirmation";
1318 // Compact workbench: waiting shows an amber "待确认" pill instead of a
1319 // spinning orange dot, and that pill replaces the relative time so the
1320 // paused-for-user state is scannable in the background tab list.
1321 const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
1322 const showWaitingPill = waitingConfirmation;
1323 const showSideTime = sideTimeVisible && !showWaitingPill;
1324 const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
1325 const topicId = node.topicId ?? "";
1326 const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
1327 const imSourceLabel = imSource?.label || "";
1328 const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
1329 const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
1330 const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
1331 const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
1332 const topicMenuOpen = !isSessionNode && menuTopic === topicId;
1333 const pinned = Boolean(node.pinned);
1334 const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
1335 const openTopicMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
1336 if (isSessionNode) return;
1337 event.preventDefault();
1338 event.stopPropagation();
1339 setMenuProject(null);
1340 setConfirmRemoveProject(null);
1341 setMenuPoint(contextMenuPointFromEvent(event));
1342 setMenuTopic(topicId);
1343 setConfirmAction(null);
1344 };
1345 const topicMenuItems: ContextMenuItem[] = [
1346 ...(projectTreeTopicMenuOffersPin(variant)
1347 ? [
1348 {
1349 key: pinned ? "unpin" : "pin",
1350 icon: <Pin size={13} />,
1351 label: pinLabel,
1352 onSelect: () => void setTopicPinned(topicId, !pinned),
1353 },
1354 ]
1355 : []),
1356 {
1357 key: "rename",
1358 icon: <Pencil size={13} />,
1359 label: t("projectTree.renameTopic"),
1360 onSelect: () => startRenameTopic(node, label),
1361 },
1362 {
1363 key: "trash",
1364 icon: <Archive size={13} />,
1365 label: confirmAction?.topicId === topicId && confirmAction.action === "trash" ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
1366 disabled: archiveBlocked,
1367 danger: true,
1368 onSelect: () => {
1369 if (confirmAction?.topicId === topicId && confirmAction.action === "trash") void trashTopic(topicId);
1370 else setConfirmAction({ topicId, action: "trash" });
1371 },
1372 },
1373 ];
1374 if (!isSessionNode && editingTopic === topicId) {
1375 return (
1376 <div
1377 key={key}
1378 className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}${imSource ? " project-tree__topic--im-source" : ""}${!classicTopics && metaFull ? " project-tree__topic--has-meta" : ""}`}
1379 style={{ paddingLeft: 14 + depth * 16 }}
1380 >
1381 <input
1382 autoFocus
1383 className="project-tree__topic-input"
1384 value={topicDraft}
1385 onChange={(event) => setTopicDraft(event.target.value)}
1386 onFocus={(event) => event.target.select()}
1387 onKeyDown={(event) => {
1388 if (event.key === "Enter") void commitRenameTopic(topicId);
1389 if (event.key === "Escape") setEditingTopic(null);
1390 }}
1391 onBlur={() => void commitRenameTopic(topicId)}
1392 />
1393 </div>
1394 );
1395 }
1396 const shortcutIndex = showShortcutBadges && isVisible && topicIndexRef.current < 9 ? topicIndexRef.current + 1 : 0;
1397 if (shortcutIndex > 0) topicIndexRef.current++;
1398 // Collect visible topics in render order for shortcut navigation
1399 if (openRequest && isVisible) {
1400 visibleTopicsCollectorRef.current.push({
1401 scope: openRequest.scope,
1402 workspaceRoot: openRequest.workspaceRoot,
1403 topicId: openRequest.topicId,
1404 sessionPath: openRequest.sessionPath,
1405 });
1406 }
1407 const row = (
1408 <div
1409 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" : ""}${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" : ""}`}
1410 style={accentStyle}
1411 onContextMenu={isSessionNode ? undefined : openTopicMenu}
1412 onMouseEnter={classicTopics ? (event) => scheduleHoverCard(event.currentTarget, key, node) : undefined}
1413 onMouseLeave={classicTopics ? cancelHoverCard : undefined}
1414 onMouseDown={classicTopics ? cancelHoverCard : undefined}
1415 >
1416 <button
1417 type="button"
1418 className="project-tree__topic-main"
1419 title={classicTopics ? undefined : title}
1420 aria-label={classicTopics ? title : undefined}
1421 style={{ paddingLeft: 14 + depth * 16 }}
1422 onClick={() => {
1423 if (!openRequest) return;
1424 const nextClick = { rowKey: key, canRename: !isSessionNode };
1425 const pending = clickTimerRef.current;
1426 if (pending !== null) {
1427 clearTimeout(pending.timer);
1428 clickTimerRef.current = null;
1429 if (projectTreeShouldSuppressOpenForRename(pending, nextClick)) return;
1430 }
1431 const timer = setTimeout(() => {
1432 if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
1433 markNodeRead(node);
1434 onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
1435 }, 200);
1436 clickTimerRef.current = { ...nextClick, timer };
1437 }}
1438 onKeyDown={(event) => {
1439 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
1440 openTopicMenu(event);
1441 }
1442 }}
1443 onDoubleClick={(event) => {
1444 if (isSessionNode) return;
1445 event.stopPropagation();
1446 if (clickTimerRef.current !== null && clickTimerRef.current.rowKey === key) {
1447 clearTimeout(clickTimerRef.current.timer);
1448 clickTimerRef.current = null;
1449 }
1450 startRenameTopic(node, label);
1451 }}
1452 >
1453 <span className="project-tree__topic-copy">
1454 <span className="project-tree__topic-heading">
1455 <span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
1456 {imSource && (
1457 <span
1458 className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
1459 title={imSourceTitle}
1460 aria-label={imSourceTitle}
1461 >
1462 <MessageSquare size={11} />
1463 <span>{imSourceLabel}</span>
1464 </span>
1465 )}
1466 {!compactTopics && statusLabel && (!classicTopics || status === "paused" || status === "error") && (
1467 <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>
1468 )}
1469 </span>
1470 </span>
1471 {sideTimeVisible && (
1472 <span className={`project-tree__topic-side${!timeLabel && !showStatusInSide && !showWaitingPill ? " project-tree__topic-side--empty" : ""}`}>
1473 {showWaitingPill && statusLabel ? (
1474 <span
1475 className="project-tree__topic-waiting-pill"
1476 title={statusLabel}
1477 >
1478 {statusLabel}
1479 </span>
1480 ) : (
1481 <>
1482 {showStatusInSide && (
1483 <span
1484 className={`project-tree__topic-state project-tree__topic-state--${status}`}
1485 title={statusLabel}
1486 aria-hidden="true"
1487 />
1488 )}
1489 {showSideTime && timeLabel && (
1490 <span className="project-tree__topic-time" aria-hidden="true">{timeLabel}</span>
1491 )}
1492 </>
1493 )}
1494 </span>
1495 )}
1496 {compactTopics && statusLabel && !showWaitingPill && (
1497 <span className="sr-only">
1498 {statusLabel}
1499 </span>
1500 )}
1501 {compactTopics && metaFull && (
1502 <span className="sr-only">
1503 {metaFull}
1504 </span>
1505 )}
1506 </button>
1507 {unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
1508 {projectTreeShouldRenderTopicActions(isSessionNode, variant, unread) && (
1509 <span
1510 className="project-tree__topic-actions"
1511 aria-label={t("projectTree.topicActions")}
1512 onMouseEnter={classicTopics ? cancelHoverCard : undefined}
1513 onFocus={classicTopics ? cancelHoverCard : undefined}
1514 >
1515 <Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
1516 <button
1517 className={`project-tree__topic-action${pinned ? " project-tree__topic-action--pinned" : ""}`}
1518 type="button"
1519 aria-label={pinLabel}
1520 aria-pressed={pinned}
1521 onClick={(event) => {
1522 event.preventDefault();
1523 event.stopPropagation();
1524 void setTopicPinned(topicId, !pinned);
1525 }}
1526 >
1527 <Pin size={15} aria-hidden="true" />
1528 </button>
1529 </Tooltip>
1530 <Tooltip label={t("projectTree.archiveTopic")} side="top" className="project-tree__topic-action-slot">
1531 <button
1532 className="project-tree__topic-action project-tree__topic-action--archive"
1533 type="button"
1534 aria-label={t("projectTree.archiveTopic")}
1535 disabled={archiveBlocked}
1536 onClick={(event) => {
1537 event.preventDefault();
1538 event.stopPropagation();
1539 void trashTopic(topicId);
1540 }}
1541 >
1542 <Archive size={15} aria-hidden="true" />
1543 </button>
1544 </Tooltip>
1545 </span>
1546 )}
1547 {!isSessionNode && (
1548 <ContextMenu
1549 open={topicMenuOpen}
1550 point={menuPoint}
1551 items={topicMenuItems}
1552 minWidth={178}
1553 ariaLabel={t("projectTree.topicActions")}
1554 onClose={closeMenu}
1555 />
1556 )}
1557 {shortcutIndex > 0 && (
1558 <span className="project-tree__topic-shortcut" aria-hidden="true">
1559 {topicShortcutLabel(shortcutIndex, shortcutPlatform)}
1560 </span>
1561 )}
1562 </div>
1563 );
1564 return (
1565 <div key={key}>
1566 {row}
1567 {hasChildren && (
1568 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1569 <div className="project-tree__children-inner">
1570 {children.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
1571 </div>
1572 </div>
1573 )}
1574 </div>
1575 );
1576 }
1577
1578 const scope = node.kind === "global_folder" ? "global" : "project";
1579 const scopeClass = scope === "global" ? " project-tree__folder--global" : " project-tree__folder--project";
1580 const pinnedClass = node.pinned ? " project-tree__folder--pinned" : "";
1581 const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
1582 const projectRoot = scope === "global" ? "" : node.root ?? "";
1583 const projectDragKey = scope === "global" ? GLOBAL_PROJECT_ORDER_KEY : projectRoot;
1584 const projectPath = node.root ?? "";
1585 const colorTargetRoot = scope === "global" ? "" : projectPath;
1586 const projectLabel = node.label || (scope === "global" ? "Global" : "Untitled");
1587 const projectPinned = Boolean(node.pinned);
1588 const projectActive = activeScope === scope && (scope === "global" || activeWorkspaceRoot === node.root);
1589 const projectMenuOpen = menuProject?.key === key;
1590 const activeTopicInProject = Boolean(activeTopicId) && activeScope === scope && (scope === "global" || activeWorkspaceRoot === projectRoot);
1591 const sourceProjectNode = tree.find((candidate) => scope === "global"
1592 ? candidate.kind === "global_folder"
1593 : candidate.kind === "project" && candidate.root === projectRoot);
1594 const activeTopicArchiveBlocked = asArray(sourceProjectNode?.children).some((candidate) =>
1595 isTopicNode(candidate) && candidate.topicId === activeTopicId && projectTreeTopicArchiveBlocked(candidate));
1596 const draggableProject = section !== "pinned" && projectDragEnabled && depth === 0 && Boolean(projectDragKey) && editingProject?.key !== key;
1597 const projectDropPosition = dropProject?.root === projectDragKey ? dropProject.position : null;
1598 const handleProjectDragStart = (event: ReactDragEvent<HTMLElement>) => {
1599 if (!draggableProject) return;
1600 const target = event.target;
1601 if (target instanceof Element && target.closest(".project-tree__action-slot,.project-tree__folder-action-slot")) {
1602 event.preventDefault();
1603 return;
1604 }
1605 event.dataTransfer.effectAllowed = "move";
1606 event.dataTransfer.setData("text/plain", projectDragKey);
1607 setDragProjectRoot(projectDragKey);
1608 setDropProject(null);
1609 };
1610 const handleProjectDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
1611 if (!draggableProject || !dragProjectRoot || dragProjectRoot === projectDragKey) return;
1612 event.preventDefault();
1613 event.dataTransfer.dropEffect = "move";
1614 const rect = event.currentTarget.getBoundingClientRect();
1615 const position: ProjectDropPosition = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
1616 setDropProject((current) => {
1617 if (current?.root === projectDragKey && current.position === position) return current;
1618 return { root: projectDragKey, position };
1619 });
1620 };
1621 const handleProjectDrop = (event: ReactDragEvent<HTMLDivElement>) => {
1622 if (!draggableProject) return;
1623 const draggedRoot = dragProjectRoot || event.dataTransfer.getData("text/plain");
1624 const position = dropProject?.root === projectDragKey ? dropProject.position : "after";
1625 event.preventDefault();
1626 clearProjectDrag();
1627 if (draggedRoot && draggedRoot !== projectDragKey) void commitProjectReorder(draggedRoot, projectDragKey, position);
1628 };
1629 const openProjectMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
1630 event.preventDefault();
1631 event.stopPropagation();
1632 setMenuTopic(null);
1633 setConfirmAction(null);
1634 setMenuPoint(contextMenuPointFromEvent(event));
1635 setMenuProject({ key, root: projectRoot, path: projectPath, scope, label: projectLabel });
1636 setConfirmRemoveProject(null);
1637 if (scope === "project" && projectRoot) {
1638 void app.DeliveryWorktreeAvailability(projectRoot).then((availability) => {
1639 setWorktreeAvailability((current) => ({
1640 ...current,
1641 [projectRoot]: { available: availability.available, reason: availability.reason },
1642 }));
1643 }).catch(() => {});
1644 }
1645 };
1646 const isolationAvailability = worktreeAvailability[projectRoot];
1647 const isolatedWorkspaceItems: ContextMenuItem[] = scope === "project"
1648 ? [{
1649 key: "isolated-delivery-workspace",
1650 icon: <GitBranch size={13} />,
1651 label: (
1652 <span title={isolationAvailability?.reason || t("projectTree.createWorktreeHint")}>
1653 {isolatingProject === projectRoot ? t("projectTree.creatingWorktree") : t("projectTree.createWorktree")}
1654 </span>
1655 ),
1656 disabled: isolatingProject !== null || isolationAvailability?.available === false,
1657 onSelect: () => { void handleCreateDeliveryWorktree(projectRoot); },
1658 }]
1659 : [];
1660 const projectMenuItems: ContextMenuItem[] = [
1661 {
1662 key: "new-session",
1663 icon: <Plus size={13} />,
1664 label: t("projectTree.newTopic"),
1665 onSelect: () => {
1666 void handleCreateTopic(scope, projectRoot, key);
1667 },
1668 },
1669 ...isolatedWorkspaceItems,
1670 {
1671 key: "rename",
1672 icon: <Pencil size={13} />,
1673 label: t("projectTree.renameProject"),
1674 onSelect: () => startRenameProject(key, projectRoot, projectLabel),
1675 },
1676 { type: "separator" as const, key: "color-separator" },
1677 ...PROJECT_COLOR_OPTIONS.map((option): ContextMenuItem => ({
1678 key: `color-${option.key || "default"}`,
1679 label: colorMenuLabel(projectColorLabel(t, option.key), option.key, (node.projectColor || "") === option.key),
1680 onSelect: () => {
1681 void setProjectColor(colorTargetRoot, option.key);
1682 },
1683 })),
1684 { type: "separator" as const, key: "path-separator" },
1685 {
1686 key: "reveal",
1687 icon: <FolderOpen size={13} />,
1688 label: t(revealLabelKey(platform)),
1689 disabled: !projectPath,
1690 onSelect: () => {
1691 void app.RevealPath(projectPath).catch(() => {});
1692 closeMenu();
1693 },
1694 },
1695 {
1696 key: "copy-path",
1697 icon: <Copy size={13} />,
1698 label: t("projectTree.copyPath"),
1699 disabled: !projectPath,
1700 onSelect: () => {
1701 void copyProjectPath(projectPath);
1702 closeMenu();
1703 },
1704 },
1705 ...(scope === "project"
1706 ? [
1707 { type: "separator" as const, key: "remove-separator" },
1708 {
1709 key: "remove",
1710 icon: <XCircle size={13} />,
1711 label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProject") : t("projectTree.removeProject"),
1712 danger: true,
1713 onSelect: () => {
1714 if (confirmRemoveProject === key) void removeProject(projectPath);
1715 else setConfirmRemoveProject(key);
1716 },
1717 },
1718 ]
1719 : []),
1720 ];
1721 const workbenchProjectMenuItems: ContextMenuItem[] = [
1722 ...(scope === "project"
1723 ? [
1724 {
1725 key: projectPinned ? "unpin-project" : "pin-project",
1726 icon: <Pin size={13} />,
1727 label: t(projectPinned ? "projectTree.unpinProject" : "projectTree.pinProject"),
1728 onSelect: () => {
1729 void setProjectPinned(projectRoot, !projectPinned);
1730 },
1731 },
1732 ]
1733 : []),
1734 ...isolatedWorkspaceItems,
1735 {
1736 key: "reveal",
1737 icon: <FolderOpen size={13} />,
1738 label: t(revealLabelKey(platform)),
1739 disabled: !projectPath,
1740 onSelect: () => {
1741 void app.RevealPath(projectPath).catch(() => {});
1742 closeMenu();
1743 },
1744 },
1745 {
1746 key: "rename",
1747 icon: <Pencil size={13} />,
1748 label: t("projectTree.renameProjectWorkbench"),
1749 onSelect: () => startRenameProject(key, projectRoot, projectLabel),
1750 },
1751 {
1752 key: "archive-active-topic",
1753 icon: <Archive size={13} />,
1754 label: activeTopicId && confirmAction?.topicId === activeTopicId && confirmAction.action === "trash"
1755 ? t("history.confirmMoveToTrash")
1756 : t("projectTree.archiveConversation"),
1757 disabled: !activeTopicInProject || !activeTopicId || activeTopicArchiveBlocked,
1758 danger: true,
1759 onSelect: () => {
1760 if (!activeTopicId) return;
1761 if (confirmAction?.topicId === activeTopicId && confirmAction.action === "trash") void trashTopic(activeTopicId);
1762 else setConfirmAction({ topicId: activeTopicId, action: "trash" });
1763 },
1764 },
1765 ...(scope === "project"
1766 ? [
1767 { type: "separator" as const, key: "remove-separator" },
1768 {
1769 key: "remove",
1770 icon: <XCircle size={13} />,
1771 label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProjectShort") : t("projectTree.removeProjectShort"),
1772 danger: true,
1773 onSelect: () => {
1774 if (confirmRemoveProject === key) void removeProject(projectPath);
1775 else setConfirmRemoveProject(key);
1776 },
1777 },
1778 ]
1779 : []),
1780 ];
1781
1782 const folderShowAll = showAllTopics.has(key);
1783 const { visible: windowedChildren, hiddenCount } = classicTruncationActive
1784 ? classicTopicWindow(children, folderShowAll)
1785 : { visible: children, hiddenCount: 0 };
1786 const windowToggleVisible = classicTruncationActive && (hiddenCount > 0 || (folderShowAll && children.length > CLASSIC_TOPIC_PREVIEW_LIMIT));
1787 const renderFolderChildren = () => {
1788 if (!hasChildren) {
1789 if (!classicTopics) return null;
1790 return (
1791 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1792 <div className="project-tree__children-inner">
1793 <div className="project-tree__topic-placeholder" style={{ paddingLeft: 14 + (depth + 1) * 16 }}>
1794 {t("projectTree.noTopics")}
1795 </div>
1796 </div>
1797 </div>
1798 );
1799 }
1800 return (
1801 <div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
1802 <div className="project-tree__children-inner">
1803 {windowedChildren.map((child) => renderNode(child, depth + 1, section, isVisible && isExpanded))}
1804 {windowToggleVisible && (
1805 <button
1806 type="button"
1807 className="project-tree__topic-window-toggle"
1808 style={{ paddingLeft: 14 + (depth + 1) * 16 }}
1809 onClick={() => toggleShowAllTopics(key)}
1810 >
1811 {hiddenCount > 0 ? t("projectTree.showMoreTopics", { n: hiddenCount }) : t("projectTree.showFewerTopics")}
1812 </button>
1813 )}
1814 </div>
1815 </div>
1816 );
1817 };
1818
1819 if (editingProject?.key === key) {
1820 return (
1821 <div key={key} className="project-tree__project-wrapper">
1822 <div
1823 className={`project-tree__folder project-tree__folder--editing${projectActive ? " project-tree__folder--active" : ""}`}
1824 style={{ paddingLeft: 8 + depth * 16 }}
1825 >
1826 <input
1827 autoFocus
1828 className="project-tree__folder-input"
1829 value={projectDraft}
1830 onChange={(event) => setProjectDraft(event.target.value)}
1831 onKeyDown={(event) => {
1832 if (event.key === "Enter") void commitRenameProject(projectRoot);
1833 if (event.key === "Escape") setEditingProject(null);
1834 }}
1835 onBlur={() => void commitRenameProject(projectRoot)}
1836 />
1837 </div>
1838 {renderFolderChildren()}
1839 </div>
1840 );
1841 }
1842
1843 return (
1844 <div key={key} className="project-tree__project-wrapper">
1845 <div
1846 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}` : ""}`}
1847 style={accentStyle}
1848 draggable={draggableProject}
1849 aria-grabbed={draggableProject ? dragProjectRoot === projectRoot : undefined}
1850 onDragStart={handleProjectDragStart}
1851 onDragOver={handleProjectDragOver}
1852 onDragLeave={(event) => {
1853 if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropProject(null);
1854 }}
1855 onDrop={handleProjectDrop}
1856 onDragEnd={clearProjectDrag}
1857 onContextMenu={openProjectMenu}
1858 >
1859 <button
1860 type="button"
1861 className="project-tree__folder-main"
1862 style={{ paddingLeft: 8 + depth * 16 }}
1863 onClick={() => {
1864 if (folderDisclosure.canExpand) toggleExpand(key);
1865 }}
1866 onKeyDown={(event) => {
1867 if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
1868 openProjectMenu(event);
1869 }
1870 }}
1871 aria-expanded={folderDisclosure.ariaExpanded}
1872 >
1873 <span className={folderDisclosure.iconStackClassName}>
1874 {folderDisclosure.isOpen ? <FolderOpen size={14} className="project-tree__folder-icon" /> : <Folder size={14} className="project-tree__folder-icon" />}
1875 </span>
1876 <span className="project-tree__folder-color" aria-hidden="true" />
1877 <span className={`project-tree__folder-label${!hasChildren ? " project-tree__folder-label--empty" : ""}`}>
1878 {projectLabel}
1879 {node.isolatedWorktree && <WorktreeBadge size={11} />}
1880 </span>
1881 </button>
1882 {compactTopics && (
1883 <Tooltip label={t("projectTree.projectActions")} className="project-tree__folder-action-slot">
1884 <button
1885 type="button"
1886 className="project-tree__folder-action project-tree__folder-action--menu"
1887 aria-label={t("projectTree.projectActions")}
1888 aria-haspopup="menu"
1889 aria-expanded={projectMenuOpen}
1890 onClick={(e) => {
1891 openProjectMenu(e);
1892 }}
1893 >
1894 <MoreHorizontal size={16} aria-hidden="true" />
1895 </button>
1896 </Tooltip>
1897 )}
1898 <Tooltip label={t("projectTree.newTopicTooltip")} className={compactTopics ? "project-tree__folder-action-slot" : "project-tree__action-slot"}>
1899 <button
1900 type="button"
1901 className={compactTopics
1902 ? `project-tree__folder-action project-tree__folder-action--create${creatingProject === key ? " project-tree__folder-action--active" : ""}`
1903 : `project-tree__new-topic${creatingProject === key ? " project-tree__new-topic--active" : ""}`}
1904 aria-label={t("projectTree.newTopicTooltip")}
1905 disabled={creatingProject !== null}
1906 onClick={(e) => {
1907 e.stopPropagation();
1908 void handleCreateTopic(scope, projectRoot, key);
1909 }}
1910 >
1911 {compactTopics ? <Plus size={15} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
1912 </button>
1913 </Tooltip>
1914 <ContextMenu
1915 open={projectMenuOpen}
1916 point={menuPoint}
1917 items={compactTopics ? workbenchProjectMenuItems : projectMenuItems}
1918 minWidth={compactTopics ? 206 : 212}
1919 ariaLabel={t("projectTree.projectActions")}
1920 onClose={closeMenu}
1921 />
1922 </div>
1923 {renderFolderChildren()}
1924 </div>
1925 );
1926 };
1927
1928 const workbenchHeaderMoreItems: ContextMenuItem[] = [
1929 {
1930 key: "archive-all",
1931 icon: <Archive size={13} />,
1932 label: t("projectTree.archiveAllConversations"),
1933 disabled: true,
1934 onSelect: () => {},
1935 },
1936 { type: "separator", key: "organize-separator" },
1937 {
1938 key: "organize-heading",
1939 icon: <Folder size={13} />,
1940 label: t("projectTree.organizeSidebar"),
1941 disabled: true,
1942 variant: "section",
1943 onSelect: () => {},
1944 },
1945 {
1946 key: "organize-project",
1947 icon: <Folder size={13} />,
1948 label: menuLabelWithCheck(t("projectTree.organizeByProject"), workbenchOrganizeMode === "project"),
1949 onSelect: () => {
1950 setWorkbenchOrganizeMode("project");
1951 closeMenu();
1952 },
1953 },
1954 {
1955 key: "organize-recent",
1956 icon: <Folder size={13} />,
1957 label: menuLabelWithCheck(t("projectTree.organizeRecentProjects"), workbenchOrganizeMode === "recent"),
1958 onSelect: () => {
1959 setWorkbenchOrganizeMode("recent");
1960 closeMenu();
1961 },
1962 },
1963 {
1964 key: "organize-time",
1965 icon: <Clock size={13} />,
1966 label: menuLabelWithCheck(t("projectTree.organizeByTime"), workbenchOrganizeMode === "time"),
1967 onSelect: () => {
1968 setWorkbenchOrganizeMode("time");
1969 closeMenu();
1970 },
1971 },
1972 {
1973 key: "move-section-down",
1974 icon: <ArrowDown size={13} />,
1975 label: t("projectTree.moveSectionDown"),
1976 disabled: true,
1977 onSelect: () => {},
1978 },
1979 { type: "separator", key: "sort-separator" },
1980 {
1981 key: "sort-heading",
1982 icon: <Clock size={13} />,
1983 label: t("projectTree.sortCriteria"),
1984 disabled: true,
1985 variant: "section",
1986 onSelect: () => {},
1987 },
1988 {
1989 key: "sort-created",
1990 icon: <Clock size={13} />,
1991 label: menuLabelWithCheck(t("projectTree.sortByCreatedAt"), workbenchSortMode === "created"),
1992 onSelect: () => {
1993 setWorkbenchSortMode("created");
1994 closeMenu();
1995 },
1996 },
1997 {
1998 key: "sort-updated",
1999 icon: <Pencil size={13} />,
2000 label: menuLabelWithCheck(t("projectTree.sortByUpdatedAt"), workbenchSortMode === "updated"),
2001 onSelect: () => {
2002 setWorkbenchSortMode("updated");
2003 closeMenu();
2004 },
2005 },
2006 ];
2007
2008 const workbenchHeaderAddItems: ContextMenuItem[] = [
2009 {
2010 key: "blank-project",
2011 icon: <FolderPlus size={13} />,
2012 label: t("projectTree.createBlankProject"),
2013 disabled: true,
2014 onSelect: () => {},
2015 },
2016 {
2017 key: "existing-folder",
2018 icon: <FolderPlus size={13} />,
2019 label: t("projectTree.useExistingFolder"),
2020 disabled: addingProject,
2021 onSelect: () => {
2022 closeMenu();
2023 void handleAddProject();
2024 },
2025 },
2026 ];
2027
2028 const timeFilterBadge = timeFilter !== "all" ? (timeFilter === "1d" ? "24h" : timeFilter) : "";
2029 const timeFilterDisplayLabel = timeFilter === "all" ? t("projectTree.timeFilterAll")
2030 : timeFilter === "10" ? t("projectTree.timeFilter10")
2031 : timeFilter === "20" ? t("projectTree.timeFilter20")
2032 : timeFilter === "1h" ? t("projectTree.timeFilter1h")
2033 : timeFilter === "3h" ? t("projectTree.timeFilter3h")
2034 : timeFilter === "5h" ? t("projectTree.timeFilter5h")
2035 : t("projectTree.timeFilter1d");
2036 const renderTimeFilterControl = (mode: "classic" | "workbench") => {
2037 const workbench = mode === "workbench";
2038 const active = timeFilter !== "all";
2039 // The classic menu also hosts the sort-criteria section, so its label
2040 // covers both; creation reuses the classic control but stays filter-only.
2041 const filterOnlyLabel = variant === "classic" ? t("projectTree.filterAndSort") : t("projectTree.timeFilter");
2042 const controlLabel = workbench ? `${t("projectTree.timeFilter")}: ${timeFilterDisplayLabel}` : filterOnlyLabel;
2043 const buttonClassName = workbench
2044 ? `project-tree__header-icon-btn project-tree__header-icon-btn--filter${active ? " project-tree__header-icon-btn--active" : ""}`
2045 : `project-tree__header-action-btn${active ? " project-tree__header-action-btn--active" : ""}`;
2046 return (
2047 <Tooltip
2048 label={controlLabel}
2049 className={`project-tree__action-slot project-tree__header-action-slot project-tree__header-action-slot--filter${workbench ? " project-tree__header-action-slot--workbench-filter" : ""}`}
2050 >
2051 <div ref={filterRef} className="project-tree__time-filter">
2052 <button
2053 ref={filterTriggerRef}
2054 type="button"
2055 className={buttonClassName}
2056 aria-label={controlLabel}
2057 aria-haspopup="menu"
2058 aria-expanded={filterMenuOpen}
2059 onClick={() => {
2060 setWorkbenchHeaderMenu(null);
2061 setMenuPoint(null);
2062 setFilterMenuOpen(!filterMenuOpen);
2063 }}
2064 >
2065 <Clock size={workbench ? 15 : 14} aria-hidden="true" />
2066 {timeFilterBadge && (
2067 <span className="project-tree__time-filter-label">
2068 {timeFilterBadge}
2069 </span>
2070 )}
2071 </button>
2072 {filterMenuOpen && (
2073 <div className="project-tree__time-filter-menu" role="menu" aria-label={filterOnlyLabel} onKeyDown={moveMenuFocus}>
2074 <button
2075 type="button"
2076 className={`project-tree__time-filter-opt${timeFilter === "all" ? " project-tree__time-filter-opt--on" : ""}`}
2077 onClick={() => { onTimeFilterChange("all"); setFilterMenuOpen(false); }}
2078 role="menuitem"
2079 >
2080 {t("projectTree.timeFilterAll")}
2081 </button>
2082 <div className="project-tree__time-filter-sep" role="separator" />
2083 <button
2084 type="button"
2085 className={`project-tree__time-filter-opt${timeFilter === "10" ? " project-tree__time-filter-opt--on" : ""}`}
2086 onClick={() => { onTimeFilterChange("10"); setFilterMenuOpen(false); }}
2087 role="menuitem"
2088 >
2089 {t("projectTree.timeFilter10")}
2090 </button>
2091 <button
2092 type="button"
2093 className={`project-tree__time-filter-opt${timeFilter === "20" ? " project-tree__time-filter-opt--on" : ""}`}
2094 onClick={() => { onTimeFilterChange("20"); setFilterMenuOpen(false); }}
2095 role="menuitem"
2096 >
2097 {t("projectTree.timeFilter20")}
2098 </button>
2099 <div className="project-tree__time-filter-sep" role="separator" />
2100 <button
2101 type="button"
2102 className={`project-tree__time-filter-opt${timeFilter === "1h" ? " project-tree__time-filter-opt--on" : ""}`}
2103 onClick={() => { onTimeFilterChange("1h"); setFilterMenuOpen(false); }}
2104 role="menuitem"
2105 >
2106 {t("projectTree.timeFilter1h")}
2107 </button>
2108 <button
2109 type="button"
2110 className={`project-tree__time-filter-opt${timeFilter === "3h" ? " project-tree__time-filter-opt--on" : ""}`}
2111 onClick={() => { onTimeFilterChange("3h"); setFilterMenuOpen(false); }}
2112 role="menuitem"
2113 >
2114 {t("projectTree.timeFilter3h")}
2115 </button>
2116 <button
2117 type="button"
2118 className={`project-tree__time-filter-opt${timeFilter === "5h" ? " project-tree__time-filter-opt--on" : ""}`}
2119 onClick={() => { onTimeFilterChange("5h"); setFilterMenuOpen(false); }}
2120 role="menuitem"
2121 >
2122 {t("projectTree.timeFilter5h")}
2123 </button>
2124 <button
2125 type="button"
2126 className={`project-tree__time-filter-opt${timeFilter === "1d" ? " project-tree__time-filter-opt--on" : ""}`}
2127 onClick={() => { onTimeFilterChange("1d"); setFilterMenuOpen(false); }}
2128 role="menuitem"
2129 >
2130 {t("projectTree.timeFilter1d")}
2131 </button>
2132 {variant === "classic" && (
2133 <>
2134 <div className="project-tree__time-filter-sep" role="separator" />
2135 <div className="project-tree__time-filter-title">{t("projectTree.sortCriteria")}</div>
2136 <button
2137 type="button"
2138 className={`project-tree__time-filter-opt${workbenchSortMode === "updated" ? " project-tree__time-filter-opt--on" : ""}`}
2139 onClick={() => { setWorkbenchSortMode("updated"); setFilterMenuOpen(false); }}
2140 role="menuitem"
2141 >
2142 {t("projectTree.sortByUpdatedAt")}
2143 </button>
2144 <button
2145 type="button"
2146 className={`project-tree__time-filter-opt${workbenchSortMode === "created" ? " project-tree__time-filter-opt--on" : ""}`}
2147 onClick={() => { setWorkbenchSortMode("created"); setFilterMenuOpen(false); }}
2148 role="menuitem"
2149 >
2150 {t("projectTree.sortByCreatedAt")}
2151 </button>
2152 </>
2153 )}
2154 </div>
2155 )}
2156 </div>
2157 </Tooltip>
2158 );
2159 };
2160
2161 const renderProjectHeader = (mode: "classic" | "workbench") => (
2162 <div className="project-tree__header">
2163 <span className="project-tree__header-title">
2164 <BriefcaseBusiness className="project-tree__header-icon" size={13} />
2165 {t("projectTree.workspaceTitle")}
2166 </span>
2167 <span className="project-tree__header-actions">
2168 {mode === "workbench" ? (
2169 <>
2170 {renderTimeFilterControl("workbench")}
2171 <Tooltip label={workbenchCollapseToggleLabel} className="project-tree__header-action-slot">
2172 <button
2173 type="button"
2174 className="project-tree__header-icon-btn"
2175 aria-label={workbenchCollapseToggleLabel}
2176 disabled={!canToggleCollapsedView}
2177 onClick={toggleCollapsedView}
2178 >
2179 {canRestoreCollapsedView ? <Maximize2 size={15} aria-hidden="true" /> : <Minimize2 size={15} aria-hidden="true" />}
2180 </button>
2181 </Tooltip>
2182 <span className="project-tree__header-menu-wrap">
2183 <Tooltip label={t("projectTree.moreActions")} className="project-tree__header-action-slot">
2184 <button
2185 type="button"
2186 className={`project-tree__header-icon-btn${workbenchHeaderMenu === "more" ? " project-tree__header-icon-btn--active" : ""}`}
2187 aria-label={t("projectTree.moreActions")}
2188 aria-haspopup="menu"
2189 aria-expanded={workbenchHeaderMenu === "more"}
2190 onClick={(event) => {
2191 openWorkbenchHeaderMenu(event, "more");
2192 }}
2193 >
2194 <MoreHorizontal size={16} aria-hidden="true" />
2195 </button>
2196 </Tooltip>
2197 <ContextMenu
2198 open={workbenchHeaderMenu === "more"}
2199 point={menuPoint}
2200 items={workbenchHeaderMoreItems}
2201 minWidth={222}
2202 ariaLabel={t("projectTree.moreActions")}
2203 onClose={closeMenu}
2204 />
2205 </span>
2206 <span className="project-tree__header-menu-wrap">
2207 <Tooltip label={t("projectTree.addProjectTooltip")} className="project-tree__header-action-slot">
2208 <button
2209 type="button"
2210 className={`project-tree__header-icon-btn${workbenchHeaderMenu === "add" ? " project-tree__header-icon-btn--active" : ""}`}
2211 aria-label={t("projectTree.addProjectTooltip")}
2212 aria-haspopup="menu"
2213 aria-expanded={workbenchHeaderMenu === "add"}
2214 disabled={addingProject}
2215 onClick={(event) => {
2216 openWorkbenchHeaderMenu(event, "add");
2217 }}
2218 >
2219 <FolderPlus size={16} aria-hidden="true" />
2220 </button>
2221 </Tooltip>
2222 <ContextMenu
2223 open={workbenchHeaderMenu === "add"}
2224 point={menuPoint}
2225 items={workbenchHeaderAddItems}
2226 minWidth={206}
2227 ariaLabel={t("projectTree.addProjectTooltip")}
2228 onClose={closeMenu}
2229 />
2230 </span>
2231 </>
2232 ) : (
2233 <>
2234 {renderTimeFilterControl("classic")}
2235 <Tooltip label={collapseToggleLabel} className="project-tree__action-slot project-tree__header-action-slot project-tree__action-slot--collapse">
2236 <button
2237 type="button"
2238 className={`project-tree__collapse-all${canRestoreCollapsedView ? " project-tree__collapse-all--restore" : ""}`}
2239 aria-label={collapseToggleLabel}
2240 aria-pressed={canRestoreCollapsedView}
2241 disabled={!canToggleCollapsedView}
2242 onClick={toggleCollapsedView}
2243 >
2244 {canRestoreCollapsedView ? <ListRestart size={14} /> : <ListCollapse size={14} />}
2245 </button>
2246 </Tooltip>
2247 <Tooltip label={t("projectTree.addProjectTooltip")} className="project-tree__action-slot project-tree__header-action-slot project-tree__action-slot--add">
2248 <button
2249 type="button"
2250 className="project-tree__add-project"
2251 aria-label={t("projectTree.addProjectTooltip")}
2252 disabled={addingProject}
2253 onClick={() => void handleAddProject()}
2254 >
2255 <FolderPlus size={14} />
2256 </button>
2257 </Tooltip>
2258 </>
2259 )}
2260 </span>
2261 </div>
2262 );
2263
2264 const renderEmptyState = () => {
2265 if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
2266 if (timeFilter !== "all") {
2267 return (
2268 <div className="project-tree__empty">{t("projectTree.emptyNoTimeFilterMatch")}
2269 <button
2270 type="button"
2271 className="project-tree__empty-primary"
2272 onClick={() => onTimeFilterChange("all")}
2273 >
2274 {t("projectTree.clearTimeFilter")}
2275 </button>
2276 </div>
2277 );
2278 }
2279 return (
2280 <div className="project-tree__empty-state">
2281 <div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.emptyNoProjects")}</div>
2282 <button
2283 type="button"
2284 className="project-tree__empty-primary"
2285 onClick={() => void handleAddProject()}
2286 disabled={addingProject}
2287 >
2288 <FolderPlus size={14} />
2289 <span>{t("projectTree.addProjectTooltip")}</span>
2290 </button>
2291 </div>
2292 );
2293 };
2294
2295 const hasTreeRows = pinnedTreeSections.pinned.length > 0 || pinnedTreeSections.projects.length > 0;
2296
2297 // Report visible topics to parent after render so shortcuts match sidebar order.
2298 // eslint-disable-next-line react-hooks/exhaustive-deps
2299 useEffect(() => {
2300 onVisibleTopicsChange?.(visibleTopicsCollectorRef.current);
2301 });
2302
2303 // Reset topic index counter and visible topics collector before each render.
2304 topicIndexRef.current = 0;
2305 visibleTopicsCollectorRef.current = [];
2306
2307 return (
2308 <div className="project-tree">
2309 {searchVisible && (
2310 <label className="project-tree__search">
2311 <Search size={14} />
2312 <input
2313 ref={searchInputRef}
2314 value={query}
2315 onChange={(event) => setQuery(event.target.value)}
2316 placeholder={t("projectTree.searchPlaceholder")}
2317 />
2318 </label>
2319 )}
2320 {compactTopics ? (
2321 <>
2322 {renderProjectHeader("workbench")}
2323 <div className="project-tree__list project-tree__list--workbench">
2324 {!hasTreeRows ? (
2325 renderEmptyState()
2326 ) : (
2327 <>
2328 {pinnedTreeSections.pinned.length > 0 && (
2329 <div className="project-tree__section project-tree__section--pinned">
2330 <div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
2331 {pinnedTreeSections.pinned.map((node) => renderNode(node, 0, "pinned"))}
2332 </div>
2333 )}
2334 <div className="project-tree__section project-tree__section--projects">
2335 {pinnedTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
2336 </div>
2337 </>
2338 )}
2339 </div>
2340 </>
2341 ) : (
2342 <>
2343 {renderProjectHeader("classic")}
2344 <div className="project-tree__list" onScroll={cancelHoverCard}>
2345 {!hasTreeRows ? (
2346 renderEmptyState()
2347 ) : (
2348 <>
2349 {pinnedTreeSections.pinned.length > 0 && (
2350 <div className="project-tree__section project-tree__section--pinned">
2351 <div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
2352 {pinnedTreeSections.pinned.map((node) => renderNode(node, 1, "pinned"))}
2353 </div>
2354 )}
2355 <div className="project-tree__section project-tree__section--projects">
2356 {pinnedTreeSections.projects.map((node) => renderNode(node, 0, "projects"))}
2357 </div>
2358 </>
2359 )}
2360 </div>
2361 </>
2362 )}
2363 {hoverCard && createPortal(
2364 <div
2365 className="project-tree__hover-card"
2366 style={{ left: hoverCard.left, top: hoverCard.top }}
2367 aria-hidden="true"
2368 >
2369 <div className="project-tree__hover-card-title">{hoverCard.card.title}</div>
2370 {hoverCard.card.statusLabel && (
2371 <div className="project-tree__hover-card-status">{hoverCard.card.statusLabel}</div>
2372 )}
2373 <div className="project-tree__hover-card-meta">
2374 {[hoverCard.card.metaLine, hoverCard.card.exactTime].filter(Boolean).join(" · ")}
2375 </div>
2376 {hoverCard.card.projectLabel && (
2377 <div className="project-tree__hover-card-project">
2378 <Folder size={12} aria-hidden="true" />
2379 <span>{hoverCard.card.projectLabel}</span>
2380 </div>
2381 )}
2382 </div>,
2383 document.body,
2384 )}
2385 </div>
2386 );
2387 }
2388
2388 lines Plain Text