@spzxg/cli-ui 0.6.1 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +145 -13
  3. package/dist/cli-ui-embed-BmLN2Jjg.js +43290 -0
  4. package/dist/{cli-ui-index-BFpRZXn4.js → cli-ui-index-D4qfPHMd.js} +2 -2
  5. package/dist/{cli-ui-jszip.min-B9tqMA-e.js → cli-ui-jszip.min-DC9898pC.js} +1 -1
  6. package/dist/{cli-ui-jszip.min-DIavJzUG.js → cli-ui-jszip.min-DpEl0G1z.js} +2 -2
  7. package/dist/{cli-ui-xlsx-BKHhqcoa.js → cli-ui-xlsx-zs7oEtPj.js} +1 -1
  8. package/dist/cli-ui.css +32 -1
  9. package/dist/cli-ui.js +1 -1
  10. package/package.json +14 -8
  11. package/src/board/BrowserPane.tsx +160 -74
  12. package/src/board/ChangesPane.tsx +2 -2
  13. package/src/board/CliBoard.tsx +79 -50
  14. package/src/board/FileViewer.tsx +32 -22
  15. package/src/board/FilesPane.tsx +32 -15
  16. package/src/board/PanelControls.tsx +28 -0
  17. package/src/board/blocks.tsx +5 -5
  18. package/src/board/context.ts +65 -0
  19. package/src/board/fileOps.ts +26 -3
  20. package/src/board/fileWatch.ts +88 -0
  21. package/src/board/folderDownload.ts +30 -14
  22. package/src/board/panels.ts +33 -0
  23. package/src/board/textLoad.ts +2 -2
  24. package/src/board/workspace.ts +38 -9
  25. package/src/chat/CliChat.tsx +113 -53
  26. package/src/chat/Composer.tsx +68 -57
  27. package/src/chat/RunControls.tsx +99 -0
  28. package/src/chat/configuration.ts +15 -0
  29. package/src/chat/draft.ts +41 -0
  30. package/src/chat/export.ts +2 -2
  31. package/src/components.tsx +13 -0
  32. package/src/core/blocks.ts +2 -2
  33. package/src/core/callbacks.ts +8 -0
  34. package/src/core/client.ts +16 -6
  35. package/src/core/config.ts +11 -15
  36. package/src/core/conversation.ts +4 -4
  37. package/src/core/id.ts +15 -0
  38. package/src/core/identity.ts +33 -0
  39. package/src/core/provider.tsx +24 -14
  40. package/src/core/store.ts +83 -26
  41. package/src/core/types.ts +34 -0
  42. package/src/elements.tsx +135 -29
  43. package/src/embed.ts +6 -8
  44. package/src/index.ts +24 -17
  45. package/src/layout/CliSplit.tsx +1 -1
  46. package/src/msglist/CliMsgList.tsx +10 -10
  47. package/src/operator/branchFlow.ts +35 -0
  48. package/src/operator/index.ts +9 -0
  49. package/src/operator/liveStream.ts +1 -0
  50. package/src/operator/node/AgentConnect.tsx +624 -0
  51. package/src/operator/node/AppearancePanel.tsx +35 -0
  52. package/src/operator/node/BranchHistory.tsx +67 -0
  53. package/src/operator/node/BranchNavigator.tsx +36 -0
  54. package/src/operator/node/BrowserPane.tsx +128 -0
  55. package/src/operator/node/BrowserPopup.tsx +22 -0
  56. package/src/operator/node/Chat.tsx +517 -0
  57. package/src/operator/node/CommandPalette.tsx +213 -0
  58. package/src/operator/node/Composer.tsx +287 -0
  59. package/src/operator/node/InsightsView.tsx +831 -0
  60. package/src/operator/node/Layout.tsx +190 -0
  61. package/src/operator/node/Markdown.tsx +61 -0
  62. package/src/operator/node/Modal.tsx +179 -0
  63. package/src/operator/node/NeedsYouMenu.tsx +54 -0
  64. package/src/operator/node/OnboardingWizard.tsx +250 -0
  65. package/src/operator/node/Orchestrator.tsx +634 -0
  66. package/src/operator/node/ProjectLanding.tsx +86 -0
  67. package/src/operator/node/ProjectsColumn.tsx +137 -0
  68. package/src/operator/node/ProviderAuth.tsx +561 -0
  69. package/src/operator/node/RunControls.tsx +2 -0
  70. package/src/operator/node/RunSwitch.tsx +113 -0
  71. package/src/operator/node/Services.tsx +245 -0
  72. package/src/operator/node/SessionRail.tsx +260 -0
  73. package/src/operator/node/SessionView.tsx +288 -0
  74. package/src/operator/node/SettingsView.tsx +690 -0
  75. package/src/operator/node/TaskBoard.tsx +352 -0
  76. package/src/operator/node/TaskChanges.tsx +530 -0
  77. package/src/operator/node/TaskHero.tsx +50 -0
  78. package/src/operator/node/TasksColumn.tsx +200 -0
  79. package/src/operator/node/Terminal.tsx +143 -0
  80. package/src/operator/node/Transcript.tsx +463 -0
  81. package/src/operator/node/ViewFile.tsx +79 -0
  82. package/src/operator/node/Welcome.tsx +101 -0
  83. package/src/operator/node/WorktreePrune.tsx +333 -0
  84. package/src/operator/node/agents.ts +58 -0
  85. package/src/operator/node/api.ts +40 -0
  86. package/src/operator/node/boardBackend.ts +80 -0
  87. package/src/operator/node/boardEvents.ts +22 -0
  88. package/src/operator/node/boardInteraction.ts +15 -0
  89. package/src/operator/node/boardState.ts +57 -0
  90. package/src/operator/node/boardWorkspace.ts +12 -0
  91. package/src/operator/node/branchFlow.ts +1 -0
  92. package/src/operator/node/composerDraft.ts +52 -0
  93. package/src/operator/node/fileLinks.ts +81 -0
  94. package/src/operator/node/format.ts +290 -0
  95. package/src/operator/node/github.tsx +234 -0
  96. package/src/operator/node/icons.tsx +88 -0
  97. package/src/operator/node/liveStream.ts +270 -0
  98. package/src/operator/node/modals.tsx +720 -0
  99. package/src/operator/node/navHistory.ts +70 -0
  100. package/src/operator/node/persist.ts +26 -0
  101. package/src/operator/node/protocol/agents/optIn.ts +9 -0
  102. package/src/operator/node/protocol/agents/types.ts +394 -0
  103. package/src/operator/node/protocol/approvalFailure.ts +77 -0
  104. package/src/operator/node/protocol/authFailure.ts +66 -0
  105. package/src/operator/node/protocol/customModels.ts +23 -0
  106. package/src/operator/node/protocol/events.ts +13 -0
  107. package/src/operator/node/protocol/features.ts +70 -0
  108. package/src/operator/node/protocol/internalUsage.ts +1 -0
  109. package/src/operator/node/protocol/promptLimits.ts +52 -0
  110. package/src/operator/node/protocol/storageRetention.ts +13 -0
  111. package/src/operator/node/protocol/types.ts +332 -0
  112. package/src/operator/node/protocol/usageLimit.ts +72 -0
  113. package/src/operator/node/shared.tsx +89 -0
  114. package/src/operator/node/transcriptModel.ts +129 -0
  115. package/src/operator/node/types.ts +296 -0
  116. package/src/operator/node/useGlobalEvents.ts +97 -0
  117. package/src/operator/node/useOrchestrator.ts +696 -0
  118. package/src/operator/node/usePrefs.ts +114 -0
  119. package/src/operator/node/useRecaps.ts +91 -0
  120. package/src/operator/node/useTaskStream.ts +279 -0
  121. package/src/operator.base.css +1798 -0
  122. package/src/operator.css +186 -16
  123. package/src/styles.css +51 -0
  124. package/src/ui/Markdown.tsx +7 -2
  125. package/src/ui/Popover.tsx +77 -0
  126. package/src/ui/TextBlock.tsx +3 -3
  127. package/src/ui/Transcript.tsx +10 -8
  128. package/src/ui/fileLinks.ts +6 -0
  129. package/src/ui/format.ts +1 -1
  130. package/src/ui/icons.tsx +1 -0
  131. package/src/ui/shared.tsx +1 -1
  132. package/src/ui/transcriptModel.ts +2 -2
  133. package/dist/cli-ui-embed-Dk2QGHxn.js +0 -41146
@@ -0,0 +1,190 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Icon } from "./icons";
5
+ import { TerminalView } from "./Terminal";
6
+ import { Skel } from "./shared";
7
+
8
+ // Skeleton stand-in for one task card (tasks column, boot skeleton). Width
9
+ // varies per index so the stack doesn't read as a repeated stamp.
10
+ export function TaskCardSkeleton({ i = 0 }: { i?: number }) {
11
+ const widths = [58, 42, 66, 38];
12
+ return (
13
+ <div className="task" style={{ cursor: "default" }} aria-hidden>
14
+ <div className="task-top">
15
+ <Skel w={9} h={9} r="50%" />
16
+ <Skel w={`${widths[i % widths.length]}%`} h={12} />
17
+ <span style={{ flex: 1 }} />
18
+ <Skel w={34} h={10} />
19
+ </div>
20
+ <div className="task-foot">
21
+ <Skel w={120} h={9} />
22
+ </div>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ // First-paint placeholder for the whole workspace, shown while the initial
28
+ // project fetch is in flight so the three columns don't flash empty. Mirrors
29
+ // the real column chrome (headers, banner, cards) so the swap to live data
30
+ // doesn't jump.
31
+ export function BootSkeleton({ mobile }: { mobile?: boolean }) {
32
+ const projectsCol = (
33
+ <div className="col col-projects" aria-hidden>
34
+ <div className="col-head"><span className="ch-title">项目</span></div>
35
+ <div className="proj-list">
36
+ {[64, 48, 56].map((w, i) => (
37
+ <div key={i} className="proj" style={{ cursor: "default" }}>
38
+ <Skel w={30} h={30} r={9} />
39
+ <div className="pmeta" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
40
+ <Skel w={`${w}%`} h={11} />
41
+ <Skel w={`${w - 20}%`} h={8} />
42
+ </div>
43
+ </div>
44
+ ))}
45
+ </div>
46
+ </div>
47
+ );
48
+ if (mobile) return projectsCol;
49
+ return (
50
+ <>
51
+ {projectsCol}
52
+ <div className="col col-tasks" aria-hidden>
53
+ <div className="proj-banner">
54
+ <div className="pb-row">
55
+ <Skel w={34} h={34} r={10} />
56
+ <Skel w={140} h={15} />
57
+ </div>
58
+ <Skel w="100%" h={40} r="var(--r)" style={{ marginTop: 10 }} />
59
+ </div>
60
+ <div className="task-scroll">
61
+ {[0, 1, 2].map((i) => <TaskCardSkeleton key={i} i={i} />)}
62
+ </div>
63
+ </div>
64
+ <div className="col col-session" aria-hidden>
65
+ <div className="empty" style={{ margin: "auto" }}>
66
+ <span className="typing"><i /><i /><i /></span>
67
+ </div>
68
+ </div>
69
+ </>
70
+ );
71
+ }
72
+
73
+ // Drag-to-resize handle sitting between two columns. By default it measures the
74
+ // column to its left (previousElementSibling) so the new width is independent of
75
+ // whatever sits further left — collapsed rails, varying project widths, etc.
76
+ // With side="right" it instead sizes the column to its right (the rail), pinning
77
+ // to that column's right edge so the width grows as you drag left. Double-click
78
+ // snaps back to the default width.
79
+ export function ColResize({ min, max, onWidth, onReset, side = "left" }: { min: number; max: number; onWidth: (w: number) => void; onReset: () => void; side?: "left" | "right" }) {
80
+ const ref = useRef<HTMLDivElement>(null);
81
+ const dragging = useRef(false);
82
+ // Keep the latest onWidth in a ref so the window listeners can be attached
83
+ // exactly once. Re-subscribing on every render (onWidth is a fresh closure)
84
+ // tears the listeners down mid-drag and drops mousemove events.
85
+ const onWidthRef = useRef(onWidth);
86
+ onWidthRef.current = onWidth;
87
+ useEffect(() => {
88
+ const move = (e: MouseEvent) => {
89
+ if (!dragging.current) return;
90
+ if (side === "right") {
91
+ const col = ref.current?.nextElementSibling as HTMLElement | null;
92
+ if (!col) return;
93
+ const right = col.getBoundingClientRect().right;
94
+ onWidthRef.current(Math.max(min, Math.min(max, Math.round(right - e.clientX))));
95
+ return;
96
+ }
97
+ const col = ref.current?.previousElementSibling as HTMLElement | null;
98
+ if (!col) return;
99
+ const left = col.getBoundingClientRect().left;
100
+ onWidthRef.current(Math.max(min, Math.min(max, Math.round(e.clientX - left))));
101
+ };
102
+ const up = () => {
103
+ if (!dragging.current) return;
104
+ dragging.current = false;
105
+ document.body.style.userSelect = "";
106
+ document.body.style.cursor = "";
107
+ };
108
+ window.addEventListener("mousemove", move);
109
+ window.addEventListener("mouseup", up);
110
+ return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); };
111
+ }, [min, max, side]);
112
+ // Zero-width seam marker; the actual grab target is an absolutely-positioned
113
+ // bar that straddles the border with a comfortable hit width and sits above the
114
+ // neighboring columns (no negative-margin overlap that could swallow clicks).
115
+ return (
116
+ <div ref={ref} className="col-resize" aria-hidden>
117
+ <span
118
+ className="col-resize-bar"
119
+ title="拖动调整宽度 · 双击恢复默认"
120
+ onMouseDown={() => { dragging.current = true; document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; }}
121
+ onDoubleClick={onReset}
122
+ />
123
+ </div>
124
+ );
125
+ }
126
+
127
+ // Collapsed sidebar — a slim vertical rail that reclaims a panel's space for the
128
+ // chat while leaving a one-click way to bring it back.
129
+ export function ColRail({ label, task, right, onExpand }: { label: string; task?: boolean; right?: boolean; onExpand: () => void }) {
130
+ return (
131
+ <button className={`col-rail${task ? " rail-task" : ""}${right ? " rail-right" : ""}`} onClick={onExpand} title={`显示「${label}」面板`}>
132
+ {Icon.chevRight()}
133
+ <span className="rail-label">{label}</span>
134
+ </button>
135
+ );
136
+ }
137
+
138
+ export function TerminalDrawer({ cwd, port, taskDir, taskTitle, visible, height, onClose, onResize }: { cwd: string; port?: number; taskDir?: string; taskTitle?: string; visible: boolean; height: number; onClose: () => void; onResize: (h: number) => void }) {
139
+ const dragging = useRef(false);
140
+ // Remount key for the terminal — bumping it kills the current shell and spawns a fresh one.
141
+ const [epoch, setEpoch] = useState(0);
142
+ // Scope pin: null = project working dir; otherwise the worktree the user
143
+ // explicitly switched to. Pinned (not derived from the selected task) so
144
+ // clicking around task cards can't respawn a shell that's running something —
145
+ // TerminalView tears down and restarts the shell whenever its cwd changes.
146
+ const [pinned, setPinned] = useState<{ path: string; title: string } | null>(null);
147
+ const effCwd = pinned ? pinned.path : cwd;
148
+ useEffect(() => {
149
+ const move = (e: MouseEvent) => {
150
+ if (!dragging.current) return;
151
+ const h = window.innerHeight - e.clientY;
152
+ onResize(Math.max(140, Math.min(h, Math.round(window.innerHeight * 0.78))));
153
+ };
154
+ const up = () => { dragging.current = false; document.body.style.userSelect = ""; };
155
+ window.addEventListener("mousemove", move);
156
+ window.addEventListener("mouseup", up);
157
+ return () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); };
158
+ }, [onResize]);
159
+
160
+ return (
161
+ <div className={`term-drawer${visible ? "" : " collapsed"}`} style={visible ? { height } : undefined}>
162
+ <div className="term-resize" onMouseDown={() => { dragging.current = true; document.body.style.userSelect = "none"; }} />
163
+ <div className="term-bar">
164
+ {Icon.terminal()}
165
+ <span className="term-title">终端</span>
166
+ <div className="bseg" aria-label="终端工作目录">
167
+ <button
168
+ className={pinned ? "" : "on"}
169
+ title="在项目的工作目录(主检出)中打开 shell"
170
+ onClick={() => setPinned(null)}
171
+ >项目</button>
172
+ <button
173
+ className={pinned ? "on" : ""}
174
+ disabled={!taskDir}
175
+ title={taskDir
176
+ ? `在所选任务的 worktree 中打开 shell${pinned && pinned.path !== taskDir ? `(当前固定在 ${pinned.title} —— 点击切换)` : ""}`
177
+ : "请选择一个已开始的任务 —— 任务在首轮运行时才会创建 worktree"}
178
+ onClick={() => { if (taskDir && pinned?.path !== taskDir) setPinned({ path: taskDir, title: taskTitle || "任务" }); }}
179
+ >任务</button>
180
+ </div>
181
+ <span className="term-cwd" title={effCwd}>{pinned ? `${pinned.title} · ${pinned.path}` : (cwd || "~(该项目未设置工作目录)")}{port ? ` · PORT=${port}` : ""}</span>
182
+ <span style={{ flex: 1 }} />
183
+ <button className="icon-btn" onClick={() => setEpoch((e) => e + 1)} title="重启终端(结束当前 shell 并启动新的)">{Icon.clear()}</button>
184
+ <button className="icon-btn" onClick={onClose} title="隐藏终端(shell 会继续运行)">{Icon.chevDown()}</button>
185
+ </div>
186
+ {/* keep TerminalView mounted even when collapsed so long-running processes (npm run dev) survive */}
187
+ <TerminalView key={epoch} cwd={effCwd} port={port} />
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { memo, useContext } from "react";
4
+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import rehypeHighlight from "rehype-highlight";
7
+ import type { ComponentProps } from "react";
8
+ import { inlineFilePath } from "../../ui/fileLinks";
9
+ import { OperatorBoardContext } from "./boardInteraction";
10
+ import { FileLinkContext, localFilePath, rewriteFileHref, rewriteFileSrc } from "./fileLinks";
11
+
12
+ // Links to local files (the agent's report.md, /abs/path/out.html, …) open
13
+ // through the task's file route when rendered inside a task (FileLinkContext);
14
+ // everything else — and every link outside a task — is left as written.
15
+ function MdLink({ href, ...props }: ComponentProps<"a">) {
16
+ const scope = useContext(FileLinkContext);
17
+ const local = rewriteFileHref(scope, href);
18
+ const open = useContext(OperatorBoardContext);
19
+ const path = localFilePath(href, scope?.baseDir);
20
+ return <a {...props} href={local ?? href} target="_blank" rel="noreferrer" title={local ? `打开文件:${href}` : props.title}
21
+ onClick={event=>{props.onClick?.(event);if(event.defaultPrevented||event.button!==0||event.metaKey||event.ctrlKey||event.shiftKey||event.altKey)return;
22
+ if(scope&&path&&open?.({kind:'file',taskId:scope.taskId,path}))event.preventDefault();}} />;
23
+ }
24
+
25
+ function MdCode({children, ...props}: ComponentProps<"code">) {
26
+ const path=inlineFilePath(children);
27
+ return path ? <MdLink href={path}><code {...props}>{children}</code></MdLink> : <code {...props}>{children}</code>;
28
+ }
29
+
30
+ function MdImg({ src, alt, ...props }: ComponentProps<"img">) {
31
+ const scope = useContext(FileLinkContext);
32
+ const local = typeof src === "string" ? rewriteFileSrc(scope, src) : null;
33
+ // eslint-disable-next-line @next/next/no-img-element
34
+ return <img {...props} alt={alt ?? ""} src={local ?? src} />;
35
+ }
36
+
37
+ // The default transform blanks any non-web scheme; let file: (and C:\ paths)
38
+ // through so MdLink can map them to the task's file route.
39
+ const urlTransform = (url: string) => (/^file:/i.test(url) || /^[a-z]:[\\/]/i.test(url) ? url : defaultUrlTransform(url));
40
+
41
+ // Renders Claude's markdown output: headings, lists, tables, fenced code blocks
42
+ // (syntax-highlighted), inline code, links. Used for assistant + user messages.
43
+ //
44
+ // Perf: `detect: false` — only fenced blocks with an explicit language get
45
+ // highlighted; hljs auto-detection ran over every bare code block and was a
46
+ // hot spot during streaming turns. Memoized so messages whose text hasn't
47
+ // changed skip the whole markdown parse + highlight on transcript re-renders.
48
+ export const Markdown = memo(function Markdown({ children }: { children: string }) {
49
+ return (
50
+ <div className="md">
51
+ <ReactMarkdown
52
+ remarkPlugins={[remarkGfm]}
53
+ rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
54
+ components={{ a: MdLink, img: MdImg, code: MdCode }}
55
+ urlTransform={urlTransform}
56
+ >
57
+ {children}
58
+ </ReactMarkdown>
59
+ </div>
60
+ );
61
+ });
@@ -0,0 +1,179 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { Priority } from "./protocol/types";
5
+ import { Icon } from "./icons";
6
+ import { jget, jsend } from "./api";
7
+ import { SLABEL, type FsListing, type TaskRow } from "./types";
8
+ import { StatusDot, Skel, ErrNote } from "./shared";
9
+
10
+ // Tracks open modals so Escape only dismisses the topmost one when modals stack
11
+ // (e.g. the folder picker opened over the project-context editor).
12
+ const modalStack: symbol[] = [];
13
+
14
+ // `title` takes a node, not just a string, so a modal can hang a control off its
15
+ // heading (the project-context modal puts a copy-id chip there). Plain strings
16
+ // still work unchanged.
17
+ export function Modal({ title, sub, onClose, children, footer, width }: { title: React.ReactNode; sub?: React.ReactNode; onClose: () => void; children: React.ReactNode; footer?: React.ReactNode; width?: number }) {
18
+ useEffect(() => {
19
+ const token = Symbol();
20
+ modalStack.push(token);
21
+ const esc = (e: KeyboardEvent) => { if (e.key === "Escape" && modalStack[modalStack.length - 1] === token) onClose(); };
22
+ window.addEventListener("keydown", esc);
23
+ return () => {
24
+ window.removeEventListener("keydown", esc);
25
+ const i = modalStack.indexOf(token);
26
+ if (i >= 0) modalStack.splice(i, 1);
27
+ };
28
+ }, [onClose]);
29
+ return (
30
+ <div className="scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
31
+ <div className="modal" style={width ? { width } : undefined}>
32
+ <div className="modal-h">
33
+ <div style={{ flex: 1 }}>
34
+ <div className="m-title">{title}</div>
35
+ {sub && <div className="m-sub" style={{ marginTop: 3 }}>{sub}</div>}
36
+ </div>
37
+ <button className="modal-close" onClick={onClose}>{Icon.x()}</button>
38
+ </div>
39
+ <div className="modal-b">{children}</div>
40
+ {footer && <div className="modal-f">{footer}</div>}
41
+ </div>
42
+ </div>
43
+ );
44
+ }
45
+
46
+ export function FolderPicker({ initial, onClose, onPick }: { initial?: string; onClose: () => void; onPick: (path: string) => void }) {
47
+ const [data, setData] = useState<FsListing | null>(null);
48
+ const [error, setError] = useState<string | null>(null);
49
+ const [loading, setLoading] = useState(true);
50
+ // Last path requested — so Retry after a failed listing re-asks for the same
51
+ // folder rather than resetting the whole picker to its initial directory.
52
+ const lastReq = useRef<string | undefined>(undefined);
53
+ const load = useCallback((p?: string) => {
54
+ lastReq.current = p;
55
+ setLoading(true);
56
+ setError(null);
57
+ jget<FsListing>(`/api/fs${p ? `?path=${encodeURIComponent(p)}` : ""}`)
58
+ .then((d) => setData(d))
59
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
60
+ .finally(() => setLoading(false));
61
+ }, []);
62
+ useEffect(() => { load(initial && initial.trim() ? initial : undefined); }, [load, initial]);
63
+
64
+ return (
65
+ <Modal title="选择工作目录" sub="选择智能体执行任务的文件夹" onClose={onClose} width={580}
66
+ footer={<>
67
+ <span className="spacer" />
68
+ <button className="btn btn-ghost" onClick={onClose}>取消</button>
69
+ <button className="btn btn-accent" disabled={!data} onClick={() => data && onPick(data.path)}>{Icon.check()} 使用此文件夹</button>
70
+ </>}>
71
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
72
+ <button className="btn btn-line" disabled={!data?.parent} onClick={() => data?.parent && load(data.parent)} title="上一级">{Icon.chevDown({ style: { transform: "rotate(180deg)" } })} 上一级</button>
73
+ <button className="btn btn-line" onClick={() => load(data?.home)} title="前往主目录">{Icon.folder()} 主目录</button>
74
+ <div className="ctx-mono" style={{ flex: 1, minWidth: 0, fontSize: 11.5, color: "var(--ink-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={data?.path}>{data?.path ?? "…"}</div>
75
+ </div>
76
+ {error && <ErrNote style={{ marginBottom: 10 }} onRetry={() => load(lastReq.current)}>{error}</ErrNote>}
77
+ <div style={{ border: "1px solid var(--line-strong)", borderRadius: "var(--r)", background: "var(--raise)", maxHeight: 320, overflowY: "auto" }}>
78
+ {loading && [56, 42, 64, 38, 50].map((w, i) => (
79
+ <div key={i} className="skel-lrow">
80
+ <Skel w={15} h={15} r={4} />
81
+ <Skel w={`${w}%`} h={11} />
82
+ </div>
83
+ ))}
84
+ {!loading && data && data.entries.length === 0 && <div className="hlp" style={{ padding: "14px 14px" }}>此处没有子文件夹。</div>}
85
+ {!loading && data && data.entries.map((e) => (
86
+ <button key={e.path} onClick={() => load(e.path)} title={e.path}
87
+ style={{ display: "flex", alignItems: "center", gap: 9, width: "100%", textAlign: "left", padding: "9px 13px", borderBottom: "1px solid var(--line)", color: "var(--ink)", fontSize: 13.5 }}>
88
+ <span style={{ color: "var(--accent)", display: "inline-flex" }}>{Icon.folder()}</span>
89
+ <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{e.name}</span>
90
+ <span style={{ color: "var(--ink-4)", display: "inline-flex" }}>{Icon.chevRight()}</span>
91
+ </button>
92
+ ))}
93
+ </div>
94
+ </Modal>
95
+ );
96
+ }
97
+
98
+ // "Browse" control for a working-dir field. Tries the OS-native folder chooser
99
+ // first (search, new-folder, Finder favorites); silently falls back to the
100
+ // in-app FolderPicker when no native dialog is available (non-macOS / headless)
101
+ // or the call errors. Cancelling the native dialog is a no-op.
102
+ export function BrowseDirButton({ initial, onPick }: { initial?: string; onPick: (p: string) => void }) {
103
+ const [browsing, setBrowsing] = useState(false);
104
+ const [busy, setBusy] = useState(false);
105
+ const browse = useCallback(async () => {
106
+ setBusy(true);
107
+ try {
108
+ const r = await jsend<{ path?: string; canceled?: boolean; unsupported?: boolean }>("/api/fs/pick-dir", "POST", { initial: initial || "" });
109
+ if (r.path) onPick(r.path);
110
+ else if (r.unsupported) setBrowsing(true);
111
+ // canceled → do nothing
112
+ } catch {
113
+ setBrowsing(true); // network/route failure → in-app fallback
114
+ } finally {
115
+ setBusy(false);
116
+ }
117
+ }, [initial, onPick]);
118
+ return (
119
+ <>
120
+ <button type="button" className="btn btn-line" style={{ flex: "none" }} disabled={busy} onClick={browse} title="浏览文件夹">{Icon.folder()} {busy ? "浏览…" : "浏览"}</button>
121
+ {browsing && <FolderPicker initial={initial} onClose={() => setBrowsing(false)} onPick={(p) => { onPick(p); setBrowsing(false); }} />}
122
+ </>
123
+ );
124
+ }
125
+
126
+ export function PrioritySeg({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) {
127
+ const opts: { key: Priority; label: string; color: string }[] = [
128
+ { key: "lo", label: "低", color: "var(--ink-4)" },
129
+ { key: "med", label: "中", color: "var(--amber)" },
130
+ { key: "hi", label: "高", color: "var(--red)" },
131
+ ];
132
+ return (
133
+ <div className="seg">
134
+ {opts.map((o) => (
135
+ <button key={o.key} className={value === o.key ? "on" : ""} onClick={() => onChange(o.key)}>
136
+ <span className="pdot" style={{ background: o.color }} />{o.label}
137
+ </button>
138
+ ))}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ // "Blocked by" picker — choose the tasks that must reach Done before this one can
144
+ // start. Candidates are the other tasks in the project (self excluded by caller).
145
+ // With any blockers selected, offers the per-task "Start when unblocked" opt-in:
146
+ // the last blocker flipping to Done launches this task's first turn by itself.
147
+ export function DepPicker({ candidates, value, onChange, autoStart, onAutoStart }: {
148
+ candidates: TaskRow[]; value: string[]; onChange: (ids: string[]) => void;
149
+ autoStart: boolean; onAutoStart: (on: boolean) => void;
150
+ }) {
151
+ const toggle = (id: string) => onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
152
+ return (
153
+ <div className="field">
154
+ <div className="lab">前置依赖 <span className="opt">— 必须先完成</span></div>
155
+ {candidates.length === 0 ? (
156
+ <div className="hlp">该项目暂无其他任务。</div>
157
+ ) : (
158
+ <div className="dep-list">
159
+ {candidates.map((c) => (
160
+ <label key={c.id} className={`dep-row ${value.includes(c.id) ? "on" : ""}`}>
161
+ <input type="checkbox" checked={value.includes(c.id)} onChange={() => toggle(c.id)} />
162
+ <StatusDot status={c.status} />
163
+ <span className="dep-title">{c.title}</span>
164
+ <span className="dep-status">{SLABEL[c.status]}</span>
165
+ </label>
166
+ ))}
167
+ </div>
168
+ )}
169
+ {value.length > 0 ? (
170
+ <label className="dep-autostart" title="当上面最后一个前置任务标记为已完成时,本任务会自动发送初始提示">
171
+ <input type="checkbox" checked={autoStart} onChange={(e) => onAutoStart(e.target.checked)} />
172
+ 解除阻塞后自动开始 <span className="opt">— 所有前置任务完成后会自动启动会话</span>
173
+ </label>
174
+ ) : (
175
+ <div className="hlp">所选任务全部标记为已完成前,本任务无法开始。</div>
176
+ )}
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Popover, LoadNote } from "./shared";
5
+ import { jget } from "./api";
6
+ import { waitedFor } from "./format";
7
+ import type { NeedsYouRow } from "./types";
8
+
9
+ // The titlebar "N need you" dropdown. Replaces the old click-to-jump pill: clicking
10
+ // the pill now opens this list of every task waiting on the user across all active
11
+ // projects, each row showing its project, title, and "waiting for <duration>" age.
12
+ // Picking a row jumps straight to that task (in its project). Rows are fetched fresh
13
+ // on open so the ages and membership are always current; the longest-waiting task
14
+ // sits at the top (server orders by waiting_since ASC).
15
+ export function NeedsYouMenu({ onJump, onClose }: { onJump: (projectId: string, taskId: string) => void; onClose: () => void }) {
16
+ const [rows, setRows] = useState<NeedsYouRow[] | null>(null);
17
+
18
+ useEffect(() => {
19
+ let alive = true;
20
+ jget<{ tasks: NeedsYouRow[] }>("/api/needs-you")
21
+ .then((d) => { if (alive) setRows(d.tasks); })
22
+ .catch(() => { if (alive) setRows([]); });
23
+ return () => { alive = false; };
24
+ }, []);
25
+
26
+ return (
27
+ <Popover onClose={onClose}>
28
+ <div className="ny-menu">
29
+ <div className="pop-sec">等待你处理</div>
30
+ {rows === null ? (
31
+ <LoadNote style={{ padding: "10px 14px 14px" }}>正在检查等待中的任务…</LoadNote>
32
+ ) : rows.length === 0 ? (
33
+ <div className="ny-empty">当前没有等待处理的任务。</div>
34
+ ) : (
35
+ rows.map((r) => (
36
+ <button
37
+ key={r.id}
38
+ className="ny-row"
39
+ onClick={() => { onJump(r.project_id, r.id); onClose(); }}
40
+ >
41
+ <span className="ny-proj" style={{ background: r.project_color }} title={r.project_name}>
42
+ {(r.project_icon || r.project_name[0] || "?").toUpperCase()}
43
+ </span>
44
+ <span className="ny-text">
45
+ <span className="ny-title">{r.title}</span>
46
+ <span className="ny-sub">{r.project_name} · 已等待 {waitedFor(r.waiting_since)}</span>
47
+ </span>
48
+ </button>
49
+ ))
50
+ )}
51
+ </div>
52
+ </Popover>
53
+ );
54
+ }