agent-nuvira 2.7.0 β†’ 2.7.2

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 (114) hide show
  1. package/dist/agents/orchestrator.d.ts.map +1 -1
  2. package/dist/agents/orchestrator.js +17 -3
  3. package/dist/agents/orchestrator.js.map +1 -1
  4. package/dist/app.js +26 -0
  5. package/dist/cli/chat.d.ts.map +1 -1
  6. package/dist/cli/chat.js +108 -2
  7. package/dist/cli/chat.js.map +1 -1
  8. package/dist/config/auth.js +18 -0
  9. package/dist/config/jwt.js +24 -0
  10. package/dist/config/keys.js +14 -0
  11. package/dist/gateway/chat-store.d.ts +32 -5
  12. package/dist/gateway/chat-store.d.ts.map +1 -1
  13. package/dist/gateway/chat-store.js +52 -6
  14. package/dist/gateway/chat-store.js.map +1 -1
  15. package/dist/gateway/registry.d.ts.map +1 -1
  16. package/dist/gateway/registry.js +54 -10
  17. package/dist/gateway/registry.js.map +1 -1
  18. package/dist/inference/model-validator.d.ts.map +1 -1
  19. package/dist/inference/model-validator.js +11 -1
  20. package/dist/inference/model-validator.js.map +1 -1
  21. package/dist/inference/tool-call-utils.d.ts +31 -0
  22. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  23. package/dist/inference/tool-call-utils.js +41 -0
  24. package/dist/inference/tool-call-utils.js.map +1 -1
  25. package/dist/learning/reasoning-trace.d.ts +5 -3
  26. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  27. package/dist/learning/reasoning-trace.js +2 -2
  28. package/dist/learning/reasoning-trace.js.map +1 -1
  29. package/dist/middleware/auth.js +30 -0
  30. package/dist/nlu/conversation-gate.js +1 -1
  31. package/dist/nlu/conversation-gate.js.map +1 -1
  32. package/dist/nlu/entities.d.ts.map +1 -1
  33. package/dist/nlu/entities.js +12 -0
  34. package/dist/nlu/entities.js.map +1 -1
  35. package/dist/nlu/intent.d.ts.map +1 -1
  36. package/dist/nlu/intent.js +83 -15
  37. package/dist/nlu/intent.js.map +1 -1
  38. package/dist/passport.js +34 -0
  39. package/dist/routes/auth.js +42 -0
  40. package/dist/routes/user.js +31 -0
  41. package/dist/server.js +17 -0
  42. package/dist/tools/pipeline-tool.d.ts.map +1 -1
  43. package/dist/tools/pipeline-tool.js +14 -0
  44. package/dist/tools/pipeline-tool.js.map +1 -1
  45. package/dist/tools/release-sync.d.ts +52 -16
  46. package/dist/tools/release-sync.d.ts.map +1 -1
  47. package/dist/tools/release-sync.js +143 -79
  48. package/dist/tools/release-sync.js.map +1 -1
  49. package/dist/web-dashboard/src/App.js +85 -0
  50. package/dist/web-dashboard/src/admin-auth.test.js +186 -0
  51. package/dist/web-dashboard/src/ansi.js +23 -0
  52. package/dist/web-dashboard/src/ansi.test.js +31 -0
  53. package/dist/web-dashboard/src/api-admin-auth.test.js +172 -0
  54. package/dist/web-dashboard/src/api-admin.test.js +65 -0
  55. package/dist/web-dashboard/src/api-hub.test.js +117 -0
  56. package/dist/web-dashboard/src/api.js +1421 -0
  57. package/dist/web-dashboard/src/api.test.js +51 -0
  58. package/dist/web-dashboard/src/artifacts.js +128 -0
  59. package/dist/web-dashboard/src/artifacts.test.js +139 -0
  60. package/dist/web-dashboard/src/components/AdminPanel.js +567 -0
  61. package/dist/web-dashboard/src/components/AdminPanel.test.js +288 -0
  62. package/dist/web-dashboard/src/components/AgentHub.js +1580 -0
  63. package/dist/web-dashboard/src/components/AgentHub.test.js +343 -0
  64. package/dist/web-dashboard/src/components/BedrockOnboarding.js +320 -0
  65. package/dist/web-dashboard/src/components/BenchmarkCharts.js +228 -0
  66. package/dist/web-dashboard/src/components/ChatPage.js +1586 -0
  67. package/dist/web-dashboard/src/components/ChatPage.test.js +899 -0
  68. package/dist/web-dashboard/src/components/ContactsPage.js +141 -0
  69. package/dist/web-dashboard/src/components/CostDashboard.js +105 -0
  70. package/dist/web-dashboard/src/components/DAGView.js +477 -0
  71. package/dist/web-dashboard/src/components/DAGView.test.js +147 -0
  72. package/dist/web-dashboard/src/components/EnvVarEditor.js +138 -0
  73. package/dist/web-dashboard/src/components/EvalsPage.js +73 -0
  74. package/dist/web-dashboard/src/components/EvalsPage.test.js +120 -0
  75. package/dist/web-dashboard/src/components/ExecutionHistory.js +201 -0
  76. package/dist/web-dashboard/src/components/GatewayPage.js +40 -0
  77. package/dist/web-dashboard/src/components/GatewayPage.test.js +74 -0
  78. package/dist/web-dashboard/src/components/HealthPanel.js +68 -0
  79. package/dist/web-dashboard/src/components/HistoryBrowser.js +34 -0
  80. package/dist/web-dashboard/src/components/Layout.js +50 -0
  81. package/dist/web-dashboard/src/components/Markdown.js +152 -0
  82. package/dist/web-dashboard/src/components/Markdown.test.js +126 -0
  83. package/dist/web-dashboard/src/components/MarkdownZeroDep.js +237 -0
  84. package/dist/web-dashboard/src/components/MemoryPanel.js +81 -0
  85. package/dist/web-dashboard/src/components/ModelTimeline.js +298 -0
  86. package/dist/web-dashboard/src/components/ModelsPanel.js +1484 -0
  87. package/dist/web-dashboard/src/components/ModelsPanel.test.js +460 -0
  88. package/dist/web-dashboard/src/components/Overview.js +123 -0
  89. package/dist/web-dashboard/src/components/PhaseTimeline.js +359 -0
  90. package/dist/web-dashboard/src/components/PhaseTimeline.test.js +234 -0
  91. package/dist/web-dashboard/src/components/PlatformConfigSection.js +384 -0
  92. package/dist/web-dashboard/src/components/PlatformConfigSection.test.js +106 -0
  93. package/dist/web-dashboard/src/components/PlatformsPage.js +250 -0
  94. package/dist/web-dashboard/src/components/QuotaPanel.js +159 -0
  95. package/dist/web-dashboard/src/components/QuotaPanel.test.js +85 -0
  96. package/dist/web-dashboard/src/components/RequestsPanel.js +229 -0
  97. package/dist/web-dashboard/src/components/RequestsPanel.test.js +103 -0
  98. package/dist/web-dashboard/src/components/RoutingInsightsPanel.js +938 -0
  99. package/dist/web-dashboard/src/components/RoutingInsightsPanel.test.js +339 -0
  100. package/dist/web-dashboard/src/components/RoutingWalkthrough.js +408 -0
  101. package/dist/web-dashboard/src/components/RoutingWalkthrough.test.js +209 -0
  102. package/dist/web-dashboard/src/components/TaskConsole.js +119 -0
  103. package/dist/web-dashboard/src/components/TaskConsole.test.js +123 -0
  104. package/dist/web-dashboard/src/components/TasksPage.js +256 -0
  105. package/dist/web-dashboard/src/components/TasksPage.test.js +103 -0
  106. package/dist/web-dashboard/src/components/TracePanel.js +306 -0
  107. package/dist/web-dashboard/src/components/WhatsAppPanel.js +195 -0
  108. package/dist/web-dashboard/src/components/WhatsAppPanel.test.js +122 -0
  109. package/dist/web-dashboard/src/jsonOrNull.js +37 -0
  110. package/dist/web-dashboard/src/main.js +15 -0
  111. package/dist/web-dashboard/src/mask.js +11 -0
  112. package/dist/web-dashboard/vite.config.js +23 -0
  113. package/dist/web-dashboard/vitest.config.js +15 -0
  114. package/package.json +5 -3
@@ -0,0 +1,1586 @@
1
+ "use strict";
2
+ /**
3
+ * ChatPage β€” P3 dashboard chat console (GUI parity with `buff chat "<prompt>"`).
4
+ *
5
+ * Each message runs ONE tool-loop turn through the real agent engine in the
6
+ * dashboard process (ChatCommand.answerOnce β€” the exact engine behind the
7
+ * CLI's single-shot chat), with the conversation threaded server-side per
8
+ * session. The reply comes back as data, and the model's suggested follow-ups
9
+ * render as clickable chips that send their prompt as the next message.
10
+ *
11
+ * Chat executes the agent, so the page is gated behind the same admin session
12
+ * + routing.operate as the other action surfaces.
13
+ *
14
+ * P8 β€” smart-rail history + real composer: the sessions sidebar collapses to
15
+ * a rail while the agent works (results own the full window), and the
16
+ * composer accepts file / paste / drag-drop attachments that ride into the
17
+ * turn as [Attachment: <name>] context.
18
+ */
19
+ var __importDefault = (this && this.__importDefault) || function (mod) {
20
+ return (mod && mod.__esModule) ? mod : { "default": mod };
21
+ };
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.default = ChatPage;
24
+ // P8 β€” attachment caps (composer). Files are read client-side and travel
25
+ // inline; pasted text beyond the threshold is offered as an attachment so the
26
+ // input box stays a message box, not a document.
27
+ const MAX_ATTACHMENT_BYTES = 300_000; // ~300 KB per attachment, 10 max
28
+ const PASTE_ATTACH_THRESHOLD = 2_000; // chars β€” larger pastes are offered as a chip
29
+ const react_1 = require("react");
30
+ const api_1 = require("../api");
31
+ const Markdown_1 = __importDefault(require("./Markdown"));
32
+ // P2 β€” structured artifacts extracted from the answer TEXT (```diff blocks,
33
+ // test/build output, deploy URLs) rendered as cards, not raw markdown.
34
+ const artifacts_1 = require("../artifacts");
35
+ const ansi_1 = require("../ansi");
36
+ /** Small icon per tool family for the card header. */
37
+ function toolIcon(tool) {
38
+ if (tool === 'read_file' || tool === 'glob' || tool === 'list_dir')
39
+ return 'πŸ“–';
40
+ if (tool === 'edit_file' || tool === 'write_file')
41
+ return '✏️';
42
+ if (tool === 'run_terminal' || tool === 'run_cli')
43
+ return 'βš™οΈ';
44
+ if (tool === 'web_search' || tool === 'read_page')
45
+ return '🌐';
46
+ if (tool === 'delegate' || tool === 'spawn_subagents')
47
+ return 'πŸ‘₯';
48
+ if (tool === 'ask_user')
49
+ return 'πŸ€”';
50
+ return 'πŸ”§';
51
+ }
52
+ /** P2 β€” status label for one inline command-run card. */
53
+ const TASK_STATUS_LABEL = {
54
+ running: '⏳ running',
55
+ done: 'βœ… done',
56
+ failed: '❌ failed',
57
+ cancelled: '⏹ cancelled',
58
+ timeout: '⏰ timed out',
59
+ error: 'πŸ’₯ error',
60
+ };
61
+ /**
62
+ * P3b β€” render a git diff card: per-file sections with +/βˆ’ colored lines
63
+ * and a change-count summary. Snapshotted into the reply so the committed
64
+ * change stays visible.
65
+ *
66
+ * P2 β€” `selectable` adds per-file accept/reject (βœ“/βœ— toggles, all accepted
67
+ * by default) + a "Commit accepted" action. The engine's git tool already
68
+ * implements the accepted-subset contract (commit with files=[...]) β€” this
69
+ * card surfaces the selection and sends it back as a chat turn; it does NOT
70
+ * re-implement diff application.
71
+ *
72
+ * P2 β€” `lockedHint` renders when the card is deliberately NOT selectable
73
+ * (extracted text diffs without an attached project): the diff is prose, not
74
+ * a known working tree, so there is nothing safe to commit against β€” the hint
75
+ * tells the user why the accept/reject affordance is absent.
76
+ */
77
+ function DiffCard({ diff, selectable = false, onCommitAccepted, lockedHint, }) {
78
+ // P2 β€” per-file accept/reject is CARD-LOCAL (all accepted by default). The
79
+ // caller only learns the final selection via onCommitAccepted.
80
+ const [accepted, setAccepted] = (0, react_1.useState)(null);
81
+ const acceptedSet = new Set(accepted ?? diff.files.map((f) => f.path));
82
+ const toggleFile = (path) => {
83
+ setAccepted((prev) => {
84
+ const base = prev ?? diff.files.map((f) => f.path);
85
+ return base.includes(path) ? base.filter((p) => p !== path) : [...base, path];
86
+ });
87
+ };
88
+ const added = diff.files.reduce((s, f) => s + (f.body.match(/^\+/gm)?.length ?? 0), 0);
89
+ const removed = diff.files.reduce((s, f) => s + (f.body.match(/^-/gm)?.length ?? 0), 0);
90
+ return (<div className="chat-diff-card">
91
+ <div className="chat-diff-head">
92
+ <span className="chat-diff-icon">πŸ”§</span>
93
+ <span className="chat-diff-title">git diff</span>
94
+ <span className="chat-diff-meta">
95
+ {diff.files.length} file{diff.files.length === 1 ? '' : 's'} Β· +{added} βˆ’{removed}
96
+ </span>
97
+ </div>
98
+ <div className="chat-diff-files">
99
+ {diff.files.map((f) => {
100
+ const isAccepted = acceptedSet.has(f.path);
101
+ return (<details key={f.path} className={`chat-diff-file${!isAccepted ? ' chat-diff-file-rejected' : ''}`} open={diff.files.length === 1}>
102
+ <summary className="chat-diff-file-path">
103
+ {selectable ? (<button type="button" className={`chat-diff-toggle${isAccepted ? ' chat-diff-toggle-on' : ''}`} title={isAccepted ? 'Accepted β€” click to reject' : 'Rejected β€” click to accept'} onClick={(e) => { e.preventDefault(); e.stopPropagation(); toggleFile(f.path); }}>
104
+ {isAccepted ? 'βœ“' : 'βœ—'}
105
+ </button>) : null}
106
+ {f.path}
107
+ </summary>
108
+ <pre className="chat-diff-body">
109
+ {f.body.split('\n').map((line, i) => {
110
+ const cls = line.startsWith('+') ? 'diff-add' : line.startsWith('-') ? 'diff-del' : line.startsWith('@@') ? 'diff-hunk' : '';
111
+ return (<div key={i} className={`chat-diff-line ${cls}`}>
112
+ {line || ' '}
113
+ </div>);
114
+ })}
115
+ </pre>
116
+ </details>);
117
+ })}
118
+ </div>
119
+ {selectable && onCommitAccepted ? (<div className="chat-diff-actions">
120
+ <button className="admin-refresh-btn" type="button" disabled={acceptedSet.size === 0} onClick={() => onCommitAccepted([...acceptedSet])}>
121
+ Commit accepted ({acceptedSet.size})
122
+ </button>
123
+ <span className="admin-hint">Only accepted files are committed β€” the agent re-confirms with a question card.</span>
124
+ </div>) : lockedHint ? (<div className="chat-diff-actions">
125
+ <span className="admin-hint chat-diff-locked">πŸ”’ {lockedHint}</span>
126
+ </div>) : null}
127
+ </div>);
128
+ }
129
+ /** P2 β€” copy-to-clipboard button (⧉ Copy β†’ βœ“ Copied), same pattern as the
130
+ * markdown code blocks. Clipboard may be unavailable (non-secure context /
131
+ * jsdom) β€” the button no-ops instead of throwing. */
132
+ function CopyButton({ text, label }) {
133
+ const [copied, setCopied] = (0, react_1.useState)(false);
134
+ const copy = async () => {
135
+ try {
136
+ await navigator.clipboard?.writeText(text);
137
+ setCopied(true);
138
+ setTimeout(() => setCopied(false), 1500);
139
+ }
140
+ catch {
141
+ /* clipboard unavailable β€” button no-ops */
142
+ }
143
+ };
144
+ return (<button type="button" className="chat-card-copy" title={`Copy ${label}`} onClick={() => void copy()}>
145
+ {copied ? 'βœ“ Copied' : '⧉ Copy'}
146
+ </button>);
147
+ }
148
+ /** P2 β€” a test/build result extracted from the answer text (βœ… / ❌ card). */
149
+ function ResultCard({ result }) {
150
+ const icon = result.verdict === 'pass' ? 'βœ…' : result.verdict === 'fail' ? '❌' : 'πŸ“‹';
151
+ return (<div className={`chat-result-card chat-result-${result.verdict}`}>
152
+ <div className="chat-result-head">
153
+ <span className="chat-result-icon">{icon}</span>
154
+ <span className="chat-result-title">{result.title}</span>
155
+ <span className="chat-result-meta">{result.verdict}</span>
156
+ <CopyButton text={result.body} label="result output"/>
157
+ </div>
158
+ <pre className="chat-result-body">{result.body}</pre>
159
+ </div>);
160
+ }
161
+ /** P2 β€” a deploy URL extracted from the answer text (πŸš€ card with a link). */
162
+ function DeployCard({ deploy }) {
163
+ return (<div className="chat-deploy-card">
164
+ <div className="chat-deploy-head">
165
+ <span className="chat-deploy-icon">πŸš€</span>
166
+ <span className="chat-deploy-title">{deploy.title || 'Deployment'}</span>
167
+ <CopyButton text={deploy.url} label="deployment URL"/>
168
+ </div>
169
+ <a className="chat-deploy-url" href={deploy.url} target="_blank" rel="noopener noreferrer">
170
+ {deploy.url}
171
+ </a>
172
+ </div>);
173
+ }
174
+ /**
175
+ * P2 β€” roving-focus keyboard navigation for the artifact card stack. Cards
176
+ * inside carry `data-artifact-card`; ↑/↓ move focus between them (wrapping at
177
+ * the ends), Home/End jump to the first/last. Native Tab still reaches each
178
+ * card's controls (copy button, toggles, links) β€” this adds list navigation
179
+ * on top, so a keyboard user can scan every artifact without tabbing through
180
+ * every control.
181
+ */
182
+ function ArtifactNav({ children }) {
183
+ const ref = (0, react_1.useRef)(null);
184
+ const onKeyDown = (e) => {
185
+ if (!ref.current)
186
+ return;
187
+ const cards = Array.from(ref.current.querySelectorAll('[data-artifact-card]'));
188
+ if (cards.length === 0)
189
+ return;
190
+ const current = cards.indexOf(document.activeElement);
191
+ let next = -1;
192
+ if (e.key === 'ArrowDown')
193
+ next = current + 1 >= cards.length ? 0 : current + 1;
194
+ else if (e.key === 'ArrowUp')
195
+ next = current - 1 < 0 ? cards.length - 1 : current - 1;
196
+ else if (e.key === 'Home')
197
+ next = 0;
198
+ else if (e.key === 'End')
199
+ next = cards.length - 1;
200
+ if (next >= 0 && next !== current) {
201
+ e.preventDefault();
202
+ cards[next].focus();
203
+ }
204
+ };
205
+ return (<div className="chat-artifacts" ref={ref} role="group" aria-label="Artifacts" onKeyDown={onKeyDown}>
206
+ {children}
207
+ </div>);
208
+ }
209
+ /**
210
+ * P2 β€” an inline command-run execution card (the ⚑ Run path). Shows the
211
+ * command, a live status badge, streamed logs, exit code + duration when
212
+ * settled, and a Cancel button while running. Reuses the P1 task runner
213
+ * (the REAL CLI as a child process) exactly like TaskConsole.
214
+ */
215
+ function TaskRunCard({ task, onCancel }) {
216
+ const running = task.status === 'running';
217
+ // P2 β€” the copy button captures the FULL output, ANSI-stripped, so what
218
+ // lands on the clipboard is the clean text the user sees (no color codes).
219
+ const outputText = task.logs.map((l) => (0, ansi_1.stripAnsi)(l.text)).join('\n');
220
+ return (<div className={`chat-task-card${running ? ' chat-task-running' : task.status === 'done' ? ' chat-task-ok' : ' chat-task-err'}`}>
221
+ <div className="chat-task-head">
222
+ <span className="chat-task-icon">βš™οΈ</span>
223
+ <code className="chat-task-cmd">{task.command}</code>
224
+ <span className="chat-task-status" title={task.status}>{TASK_STATUS_LABEL[task.status]}</span>
225
+ {!running && task.exitCode !== null ? <span className="chat-task-exit">exit {task.exitCode}</span> : null}
226
+ {!running && task.durationMs !== null ? <span className="chat-task-dur">{task.durationMs}ms</span> : null}
227
+ {outputText ? <CopyButton text={outputText} label="task output"/> : null}
228
+ {running && onCancel ? (<button className="admin-mini-btn" type="button" onClick={onCancel}>⏹ Cancel</button>) : null}
229
+ </div>
230
+ <pre className="chat-task-logs" role="log">
231
+ {task.logs.length > 0 ? (task.logs.map((l, i) => {
232
+ // Stream separation: a divider marks where the output switched
233
+ // streams (stdout β†’ stderr β†’ system), and each line is styled by
234
+ // its stream. ANSI escapes (chalk colors, progress-bar cursor
235
+ // control) are stripped so the card shows clean text.
236
+ const prev = task.logs[i - 1];
237
+ const switched = prev && prev.stream !== l.stream;
238
+ const text = (0, ansi_1.stripAnsi)(l.text) || ' ';
239
+ return (<div key={i}>
240
+ {switched ? (<div className={`chat-task-log-sep chat-task-log-sep-${l.stream}`} aria-hidden="true">
241
+ {l.stream}
242
+ </div>) : null}
243
+ <div className={`chat-task-log chat-task-log-${l.stream}`}>{text}</div>
244
+ </div>);
245
+ })) : (<div className="admin-hint">{running ? 'Waiting for output…' : '(no output)'}</div>)}
246
+ </pre>
247
+ </div>);
248
+ }
249
+ /**
250
+ * P6a β€” the /learn preview card: the agent drafted a skill; the user
251
+ * decides βœ… accept (saves it to the live stores), ✏️ edit (asks the agent
252
+ * to revise β€” a chat turn re-drafts), ↩ reject (discards the draft).
253
+ */
254
+ function SkillDraftCard({ draft, onAccept, onReject, onEdit }) {
255
+ const status = draft.status ?? 'pending';
256
+ const lines = draft.markdown.split('\n');
257
+ const bodyStart = lines.findIndex((l) => l.startsWith('# ')) >= 0 ? lines.findIndex((l) => l.startsWith('# ')) : 0;
258
+ const preview = lines.slice(bodyStart, bodyStart + 14).join('\n');
259
+ return (<div className={`chat-draft-card${status === 'saved' ? ' chat-draft-saved' : ''}${status === 'rejected' ? ' chat-draft-rejected' : ''}`}>
260
+ <div className="chat-draft-head">
261
+ <span className="chat-draft-icon">🧠</span>
262
+ <span className="chat-draft-title">New skill draft: {draft.name}</span>
263
+ <span className="chat-draft-meta">pending your review</span>
264
+ </div>
265
+ <p className="chat-draft-desc">{draft.description}</p>
266
+ <pre className="chat-draft-body">{preview}</pre>
267
+ {status === 'saving' ? (<div className="admin-hint">Saving…</div>) : status === 'saved' ? (<div className="admin-hint">βœ… Saved β€” the skill is live: load it in chat or see it in the Agent Hub Skills tab.</div>) : status === 'rejected' ? (<div className="admin-hint">πŸ—‘οΈ Rejected β€” the draft was discarded, nothing was saved.</div>) : (<div className="chat-draft-actions">
268
+ <button className="admin-refresh-btn" type="button" onClick={() => onAccept(draft.name)}>
269
+ βœ… Accept
270
+ </button>
271
+ <button className="admin-mini-btn" type="button" onClick={() => onEdit(draft.name)}>
272
+ ✏️ Edit
273
+ </button>
274
+ <button className="admin-mini-btn" type="button" onClick={() => onReject(draft.name)}>
275
+ ↩ Reject
276
+ </button>
277
+ </div>)}
278
+ </div>);
279
+ }
280
+ /**
281
+ * PA4 β€” notification card for skill env-var requirements.
282
+ * Non-blocking: shows which env vars a loaded skill needs and whether
283
+ * they are already persisted. Each var gets an input field so the user
284
+ * can set them directly from the dashboard.
285
+ */
286
+ function SecretRequestCard({ request, onSave }) {
287
+ const [values, setValues] = (0, react_1.useState)({});
288
+ const [saved, setSaved] = (0, react_1.useState)(false);
289
+ const allSet = request.missing.every((k) => values[k]?.trim());
290
+ const alreadyPersisted = request.missing.filter((k) => request.persisted[k]);
291
+ const stillNeeded = request.missing.filter((k) => !request.persisted[k]);
292
+ return (<div className="chat-secret-card">
293
+ <div className="chat-secret-head">
294
+ <span className="chat-secret-icon">πŸ”</span>
295
+ <span className="chat-secret-title">{request.skillName} needs environment variables</span>
296
+ </div>
297
+ {alreadyPersisted.length > 0 ? (<div className="chat-secret-persisted">
298
+ Already configured: {alreadyPersisted.map((k) => <code key={k}>{k}</code>).join(', ')}
299
+ </div>) : null}
300
+ {stillNeeded.length > 0 && !saved ? (<div className="chat-secret-fields">
301
+ {stillNeeded.map((k) => (<div key={k} className="chat-secret-row">
302
+ <label className="chat-secret-label" htmlFor={`secret-${k}`}>{k}:</label>
303
+ <input id={`secret-${k}`} className="chat-secret-input" type="password" placeholder={`Enter ${k} value…`} value={values[k] ?? ''} onChange={(e) => setValues((v) => ({ ...v, [k]: e.target.value }))}/>
304
+ </div>))}
305
+ <button className="admin-refresh-btn" type="button" disabled={!allSet} onClick={() => { onSave(values); setSaved(true); }}>
306
+ πŸ’Ύ Save to ~/.buff/.env
307
+ </button>
308
+ </div>) : saved ? (<div className="admin-hint">βœ… Saved β€” the skill will pick up these values on next load.</div>) : (<div className="admin-hint">All required env vars are already configured.</div>)}
309
+ </div>);
310
+ }
311
+ /**
312
+ * Execution Result Card β€” shows the outcome of a skill execution.
313
+ * Displays runtime, duration, exit code, stdout/stderr, and status.
314
+ */
315
+ function ExecutionResultCard({ result }) {
316
+ const [expanded, setExpanded] = (0, react_1.useState)(false);
317
+ const statusIcon = result.success ? 'βœ…' : '❌';
318
+ const statusText = result.success ? 'Success' : `Failed (exit ${result.exitCode})`;
319
+ const statusClass = result.success ? 'chat-exec-success' : 'chat-exec-failure';
320
+ const timeStr = new Date(result.timestamp).toLocaleTimeString();
321
+ return (<div className={`chat-exec-card ${statusClass}`}>
322
+ <div className="chat-exec-head" onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer' }}>
323
+ <span className="chat-exec-icon">πŸ“œ</span>
324
+ <span className="chat-exec-title">{result.skillName}</span>
325
+ <span className="chat-exec-status">{statusIcon} {statusText}</span>
326
+ <span className="chat-exec-meta">{result.runtime} Β· {result.durationMs}ms Β· {timeStr}</span>
327
+ <span className="chat-exec-expand">{expanded ? 'β–Ό' : 'β–Ά'}</span>
328
+ </div>
329
+ {expanded && (<div className="chat-exec-body">
330
+ {result.stdout && (<div className="chat-exec-section">
331
+ <div className="chat-exec-section-title">stdout</div>
332
+ <pre className="chat-exec-output">{result.stdout}</pre>
333
+ </div>)}
334
+ {result.stderr && (<div className="chat-exec-section chat-exec-stderr">
335
+ <div className="chat-exec-section-title">stderr</div>
336
+ <pre className="chat-exec-output">{result.stderr}</pre>
337
+ </div>)}
338
+ <div className="chat-exec-details">
339
+ <span>Runtime: {result.runtime}</span>
340
+ <span>Exit code: {result.exitCode}</span>
341
+ <span>Duration: {result.durationMs}ms</span>
342
+ </div>
343
+ </div>)}
344
+ </div>);
345
+ }
346
+ /** P0.7 β€” status icon for one checklist step. */
347
+ function planStepIcon(status) {
348
+ if (status === 'done')
349
+ return 'βœ…';
350
+ if (status === 'running')
351
+ return 'πŸ”„';
352
+ if (status === 'blocked')
353
+ return 'β›”';
354
+ return '⬜';
355
+ }
356
+ /**
357
+ * P0.7 β€” render the plan checklist card: goal header + per-step status + a
358
+ * progress count ("3/5 done"). Updates in place as `plan` events arrive
359
+ * (each new revision replaces the card content).
360
+ */
361
+ function PlanCard({ plan }) {
362
+ const done = plan.steps.filter((s) => s.status === 'done').length;
363
+ return (<div className="chat-plan-card">
364
+ <div className="chat-plan-head">
365
+ <span className="chat-plan-icon">πŸ—‚οΈ</span>
366
+ <span className="chat-plan-goal">{plan.goal}</span>
367
+ <span className="chat-plan-count">{done}/{plan.steps.length} done</span>
368
+ </div>
369
+ <div className="chat-plan-steps">
370
+ {plan.steps.map((s) => (<div key={s.id} className={`chat-plan-step chat-plan-step-${s.status}`}>
371
+ <span className="chat-plan-step-icon">{planStepIcon(s.status)}</span>
372
+ <span className="chat-plan-step-text">{s.description}</span>
373
+ <span className="chat-plan-step-status">{s.status}</span>
374
+ </div>))}
375
+ </div>
376
+ </div>);
377
+ }
378
+ /**
379
+ * P0.6 β€” render a tool-call as a card: icon + name + one-line args, a status
380
+ * badge (⏳ running / βœ“ ok / βœ— error), duration when finished, and a
381
+ * collapsible result/error body. `live` renders the running state (phase
382
+ * 'started' still spinning); snapshotted message cards are always settled.
383
+ */
384
+ function ToolCards({ tools, live }) {
385
+ if (!tools || tools.length === 0)
386
+ return null;
387
+ return (<div className={`chat-tool-cards${live ? ' chat-tool-cards-live' : ''}`}>
388
+ {tools.map((t) => {
389
+ const running = t.phase === 'started' || t.ok === undefined;
390
+ const failed = t.ok === false;
391
+ return (<div key={t.id} className={`chat-tool-card${running ? ' chat-tool-running' : failed ? ' chat-tool-err' : ' chat-tool-ok'}`}>
392
+ <div className="chat-tool-head">
393
+ <span className="chat-tool-icon">{toolIcon(t.tool)}</span>
394
+ <span className="chat-tool-name">{t.tool}</span>
395
+ {t.args ? <code className="chat-tool-args">{t.args}</code> : null}
396
+ <span className="chat-tool-status" title={running ? 'running' : failed ? 'failed' : 'done'}>
397
+ {running ? '⏳' : failed ? 'βœ—' : 'βœ“'}
398
+ </span>
399
+ {t.durationMs !== undefined && !running ? <span className="chat-tool-dur">{t.durationMs}ms</span> : null}
400
+ </div>
401
+ {t.error || t.result ? (<details className="chat-tool-detail">
402
+ <summary>{failed ? 'Error' : 'Result'}</summary>
403
+ <pre className="chat-tool-body">{t.error || t.result}</pre>
404
+ </details>) : null}
405
+ </div>);
406
+ })}
407
+ </div>);
408
+ }
409
+ function newSessionId() {
410
+ try {
411
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
412
+ return crypto.randomUUID();
413
+ }
414
+ catch { /* fall through */ }
415
+ return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
416
+ }
417
+ /** P8 β€” group sessions by recency (Today / Yesterday / This week / Older)
418
+ * and filter by the sidebar search box (title / preview / first message). */
419
+ function groupSessions(sessions, query) {
420
+ const q = query.trim().toLowerCase();
421
+ const filtered = q
422
+ ? sessions.filter((s) => `${s.title} ${s.preview} ${s.firstUser}`.toLowerCase().includes(q))
423
+ : sessions;
424
+ const groups = [];
425
+ const now = new Date();
426
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
427
+ const dayMs = 86_400_000;
428
+ const buckets = [
429
+ { label: 'Today', match: (t) => t >= startOfToday },
430
+ { label: 'Yesterday', match: (t) => t >= startOfToday - dayMs && t < startOfToday },
431
+ { label: 'This week', match: (t) => t >= startOfToday - 7 * dayMs && t < startOfToday - dayMs },
432
+ { label: 'Older', match: () => true },
433
+ ];
434
+ for (const b of buckets) {
435
+ const items = filtered.filter((s) => b.match(s.updatedAt));
436
+ if (items.length > 0)
437
+ groups.push({ label: b.label, items });
438
+ }
439
+ return groups;
440
+ }
441
+ function ChatPage() {
442
+ const [auth, setAuth] = (0, react_1.useState)(null);
443
+ const [messages, setMessages] = (0, react_1.useState)([]);
444
+ const [input, setInput] = (0, react_1.useState)('');
445
+ const [busy, setBusy] = (0, react_1.useState)(false);
446
+ const [error, setError] = (0, react_1.useState)('');
447
+ const [meta, setMeta] = (0, react_1.useState)(null);
448
+ const [liveSteps, setLiveSteps] = (0, react_1.useState)([]);
449
+ // P0.6 β€” live tool-call cards (upserted by id: started creates, called completes).
450
+ const [liveTools, setLiveTools] = (0, react_1.useState)([]);
451
+ // P0.7 β€” the live plan checklist (updates in place on each plan:changed).
452
+ const [livePlan, setLivePlan] = (0, react_1.useState)(null);
453
+ // P3b β€” the latest git diff payload (rendered as a diff card).
454
+ const [liveDiff, setLiveDiff] = (0, react_1.useState)(null);
455
+ // P6a β€” the /learn preview card (skill_manage create/patch emits it).
456
+ const [liveDraft, setLiveDraft] = (0, react_1.useState)(null);
457
+ // PA4 β€” skill env-var notification card (non-blocking: shows missing vars).
458
+ const [secretRequests, setSecretRequests] = (0, react_1.useState)([]);
459
+ const [executionResults, setExecutionResults] = (0, react_1.useState)([]);
460
+ // Plain-English β†’ CLI short-circuit: a confident command match shows a
461
+ // confirm card instead of burning a model turn; ambiguous asks show choices.
462
+ const [pendingResolve, setPendingResolve] = (0, react_1.useState)(null);
463
+ // Mirrors liveSteps/liveTools for the async send callback (state would be
464
+ // stale in the closure when the POST resolves) β€” the final message snapshots
465
+ // every step.
466
+ const liveStepsRef = (0, react_1.useRef)([]);
467
+ const liveToolsRef = (0, react_1.useRef)([]);
468
+ const livePlanRef = (0, react_1.useRef)(null);
469
+ const liveDiffRef = (0, react_1.useRef)(null);
470
+ const liveDraftRef = (0, react_1.useRef)(null);
471
+ // P4 β€” the live answer typewriter: tokens stream in via SSE while the POST
472
+ // is in flight. The POST response is AUTHORITATIVE (the engine's S1
473
+ // longest-substantive logic may pick an earlier, longer answer) β€” the
474
+ // streamed text is replaced, not merged, when the turn resolves.
475
+ const [streamingText, setStreamingText] = (0, react_1.useState)('');
476
+ const streamingRef = (0, react_1.useRef)('');
477
+ // P4 β€” the in-flight turn's AbortController (the Cancel button aborts the
478
+ // POST fetch; the server then cancels the turn server-side).
479
+ const abortRef = (0, react_1.useRef)(null);
480
+ // P4 β€” the last message whose turn FAILED, for the Retry affordance (null
481
+ // when nothing to retry).
482
+ const [retryAsk, setRetryAsk] = (0, react_1.useState)(null);
483
+ const sessionIdRef = (0, react_1.useRef)(newSessionId());
484
+ // Phase 6 β€” the last sent message (↑ recalls it into the box).
485
+ const lastSentRef = (0, react_1.useRef)('');
486
+ // P4 β€” the session sidebar: past conversations, click to resume.
487
+ const [sessions, setSessions] = (0, react_1.useState)([]);
488
+ // P8 β€” smart rail: the sidebar collapses to a rail while the agent works and
489
+ // returns when the turn finishes (results own the full window mid-task).
490
+ const [railOpen, setRailOpen] = (0, react_1.useState)(true);
491
+ // P8 β€” sidebar search filter (title / preview / first message).
492
+ const [sessionQuery, setSessionQuery] = (0, react_1.useState)('');
493
+ // P8 β€” the session being renamed (inline input in the sidebar).
494
+ const [renamingId, setRenamingId] = (0, react_1.useState)(null);
495
+ const [renameValue, setRenameValue] = (0, react_1.useState)('');
496
+ // P8 β€” composer attachments (file picker / paste-as-attachment / drag-drop).
497
+ const [attachments, setAttachments] = (0, react_1.useState)([]);
498
+ const [pasteOffer, setPasteOffer] = (0, react_1.useState)(null);
499
+ const [dragOver, setDragOver] = (0, react_1.useState)(false);
500
+ const fileInputRef = (0, react_1.useRef)(null);
501
+ const pastePosRef = (0, react_1.useRef)(null);
502
+ // P3 β€” the attached project (its bounded context rides into every turn).
503
+ const [attachedProject, setAttachedProject] = (0, react_1.useState)(null);
504
+ // P4b β€” the project path from a resumed session (used to show a mismatch banner).
505
+ const [sessionProjectPath, setSessionProjectPath] = (0, react_1.useState)(null);
506
+ const [projectPick, setProjectPick] = (0, react_1.useState)([]);
507
+ const [projectPathInput, setProjectPathInput] = (0, react_1.useState)('');
508
+ const [projectError, setProjectError] = (0, react_1.useState)('');
509
+ // Folder browser popover for the project picker.
510
+ const [browseOpen, setBrowseOpen] = (0, react_1.useState)(false);
511
+ const [browsePath, setBrowsePath] = (0, react_1.useState)('');
512
+ const [browseEntries, setBrowseEntries] = (0, react_1.useState)([]);
513
+ const [browseParent, setBrowseParent] = (0, react_1.useState)(null);
514
+ const [browseLoading, setBrowseLoading] = (0, react_1.useState)(false);
515
+ const [browseDrives, setBrowseDrives] = (0, react_1.useState)([]);
516
+ const [browseBreadcrumbs, setBrowseBreadcrumbs] = (0, react_1.useState)([]);
517
+ const [browseFilter, setBrowseFilter] = (0, react_1.useState)('');
518
+ const browseRefreshRef = (0, react_1.useRef)(null);
519
+ const subRef = (0, react_1.useRef)(null);
520
+ const listRef = (0, react_1.useRef)(null);
521
+ // P4b β€” ref to break the circular dependency between resumeSession and attachProject.
522
+ const attachProjectRef = (0, react_1.useRef)(null);
523
+ // P0.1 β€” a pending ask_user question from the agent (choice card).
524
+ const [pendingQuestion, setPendingQuestion] = (0, react_1.useState)(null);
525
+ const [questionSel, setQuestionSel] = (0, react_1.useState)(new Set());
526
+ (0, react_1.useEffect)(() => {
527
+ void api_1.dashboardAPI.fetchAdminAuthStatus().then((s) => {
528
+ setAuth(s
529
+ ? { configured: s.configured, authenticated: s.authenticated, role: s.role }
530
+ : { configured: false, authenticated: false, role: null });
531
+ });
532
+ }, []);
533
+ // Auto-scroll the thread to the newest message.
534
+ (0, react_1.useEffect)(() => {
535
+ try {
536
+ listRef.current?.scrollTo?.({ top: listRef.current.scrollHeight });
537
+ }
538
+ catch {
539
+ /* jsdom / non-DOM environments */
540
+ }
541
+ }, [messages, busy, liveSteps]);
542
+ // Tear down the SSE subscription on unmount.
543
+ (0, react_1.useEffect)(() => {
544
+ return () => {
545
+ subRef.current?.();
546
+ subRef.current = null;
547
+ };
548
+ }, []);
549
+ /** P4 β€” resume a past session: load its transcript into the thread. */
550
+ const resumeSession = (0, react_1.useCallback)(async (id) => {
551
+ subRef.current?.();
552
+ subRef.current = null;
553
+ const rec = await api_1.dashboardAPI.getChatSession(id);
554
+ if (!rec)
555
+ return;
556
+ sessionIdRef.current = id;
557
+ setMessages(rec.turns.map((t) => ({
558
+ role: t.role,
559
+ content: t.content,
560
+ ...(t.role === 'assistant' ? { followups: [], artifacts: (0, artifacts_1.extractArtifacts)(t.content) } : {}),
561
+ })));
562
+ setLiveSteps([]);
563
+ liveStepsRef.current = [];
564
+ setLiveTools([]);
565
+ liveToolsRef.current = [];
566
+ setLivePlan(null);
567
+ livePlanRef.current = null;
568
+ setLiveDiff(null);
569
+ liveDiffRef.current = null;
570
+ setLiveDraft(null);
571
+ liveDraftRef.current = null;
572
+ streamingRef.current = '';
573
+ setStreamingText('');
574
+ setRetryAsk(null);
575
+ setError('');
576
+ setMeta(null);
577
+ setPendingResolve(null);
578
+ setPendingQuestion(null);
579
+ setRailOpen(true);
580
+ // P4b β€” auto-restore the attached project from the session's stored path.
581
+ const storedPath = rec.projectPath;
582
+ if (storedPath) {
583
+ setSessionProjectPath(storedPath);
584
+ // If the project is already attached and matches, no action needed.
585
+ if (attachedProject?.path === storedPath)
586
+ return;
587
+ // Try to re-attach: the server will build the context bundle if the dir exists.
588
+ void attachProjectRef.current?.(storedPath);
589
+ }
590
+ else {
591
+ setSessionProjectPath(null);
592
+ }
593
+ }, [attachedProject]);
594
+ /** P8 β€” start a fresh session (sidebar + New chat button). */
595
+ const newChat = (0, react_1.useCallback)(() => {
596
+ subRef.current?.();
597
+ subRef.current = null;
598
+ sessionIdRef.current = newSessionId();
599
+ setSessionProjectPath(null);
600
+ setMessages([]);
601
+ setLiveSteps([]);
602
+ liveStepsRef.current = [];
603
+ setLiveTools([]);
604
+ liveToolsRef.current = [];
605
+ setLivePlan(null);
606
+ livePlanRef.current = null;
607
+ setLiveDiff(null);
608
+ liveDiffRef.current = null;
609
+ setLiveDraft(null);
610
+ liveDraftRef.current = null;
611
+ streamingRef.current = '';
612
+ setStreamingText('');
613
+ setRetryAsk(null);
614
+ setError('');
615
+ setMeta(null);
616
+ setPendingResolve(null);
617
+ setPendingQuestion(null);
618
+ setAttachments([]);
619
+ setPasteOffer(null);
620
+ setInput('');
621
+ setRailOpen(true);
622
+ }, []);
623
+ /** P8 β€” add an attachment chip (file picker / drag-drop / paste-as-text). */
624
+ const addAttachment = (0, react_1.useCallback)((chip) => {
625
+ setAttachments((a) => [...a, chip].slice(-10));
626
+ }, []);
627
+ const removeAttachment = (0, react_1.useCallback)((index) => {
628
+ setAttachments((a) => a.filter((_, i) => i !== index));
629
+ }, []);
630
+ /** P8 β€” file picker: read the file as text and chip it. */
631
+ const pickFile = (0, react_1.useCallback)(async (file) => {
632
+ if (!file)
633
+ return;
634
+ if (file.size > MAX_ATTACHMENT_BYTES) {
635
+ setError(`Attachment "${file.name}" is too large (max ${Math.round(MAX_ATTACHMENT_BYTES / 1024)} KB).`);
636
+ return;
637
+ }
638
+ const text = await file.text();
639
+ addAttachment({ name: file.name, content: text, kind: 'file' });
640
+ }, [addAttachment]);
641
+ /** P8 β€” the textarea's paste handler: remember the cursor so the offer can
642
+ * strip exactly the pasted text when accepted. */
643
+ const handlePaste = (0, react_1.useCallback)((e) => {
644
+ const el = e.currentTarget;
645
+ const pasted = e.clipboardData.getData('text') || '';
646
+ if (pasted.length >= PASTE_ATTACH_THRESHOLD) {
647
+ pastePosRef.current = { start: el.selectionStart, end: el.selectionEnd };
648
+ setPasteOffer({ text: pasted });
649
+ }
650
+ }, []);
651
+ /** P8 β€” accept the paste offer: strip the pasted text out of the box and
652
+ * turn it into an attachment chip. */
653
+ const acceptPasteOffer = (0, react_1.useCallback)(() => {
654
+ const offer = pasteOffer;
655
+ if (!offer)
656
+ return;
657
+ const pos = pastePosRef.current;
658
+ setInput((prev) => {
659
+ if (!pos)
660
+ return prev;
661
+ return prev.slice(0, pos.start) + prev.slice(pos.end);
662
+ });
663
+ addAttachment({ name: `pasted-text.txt`, content: offer.text, kind: 'paste' });
664
+ setPasteOffer(null);
665
+ pastePosRef.current = null;
666
+ }, [pasteOffer, addAttachment]);
667
+ /** P8 β€” rename a session (sidebar pencil). */
668
+ const startRename = (0, react_1.useCallback)((s) => {
669
+ setRenamingId(s.id);
670
+ setRenameValue(s.title === '(untitled conversation)' ? '' : s.title);
671
+ }, []);
672
+ const commitRename = (0, react_1.useCallback)(async (id) => {
673
+ const r = await api_1.dashboardAPI.renameChatSession(id, renameValue);
674
+ if (r.ok) {
675
+ setSessions((list) => list.map((s) => (s.id === id ? { ...s, title: renameValue.trim() || s.title } : s)));
676
+ }
677
+ else {
678
+ setError(r.error || 'Could not rename the session.');
679
+ }
680
+ setRenamingId(null);
681
+ setRenameValue('');
682
+ }, [renameValue]);
683
+ /** P8 β€” delete a session (sidebar trash). */
684
+ const deleteSession = (0, react_1.useCallback)(async (id) => {
685
+ const r = await api_1.dashboardAPI.deleteChatSession(id);
686
+ if (r.ok) {
687
+ setSessions((list) => list.filter((s) => s.id !== id));
688
+ if (sessionIdRef.current === id)
689
+ newChat();
690
+ }
691
+ else {
692
+ setError(r.error || 'Could not delete the session.');
693
+ }
694
+ }, [newChat]);
695
+ const canChat = auth?.authenticated === true && (auth.role === 'admin' || auth.role === 'operator');
696
+ // P4 β€” load the session sidebar once authenticated (admin/operator only).
697
+ (0, react_1.useEffect)(() => {
698
+ if (!canChat)
699
+ return;
700
+ void api_1.dashboardAPI.listChatSessions().then((s) => {
701
+ if (Array.isArray(s))
702
+ setSessions(s);
703
+ });
704
+ // P3 β€” load the project picker (dashboard cwd + recently attached).
705
+ void api_1.dashboardAPI.listProjects().then((p) => {
706
+ if (Array.isArray(p))
707
+ setProjectPick(p);
708
+ });
709
+ }, [canChat]);
710
+ /** P3 β€” attach a project directory (its context rides into chat turns). */
711
+ const attachProject = (0, react_1.useCallback)(async (path) => {
712
+ const clean = path.trim();
713
+ if (!clean)
714
+ return;
715
+ setProjectError('');
716
+ const r = await api_1.dashboardAPI.attachProject(clean);
717
+ if (r.ok && r.project) {
718
+ setAttachedProject(r.project);
719
+ setProjectPathInput('');
720
+ const p = await api_1.dashboardAPI.listProjects();
721
+ if (Array.isArray(p))
722
+ setProjectPick(p);
723
+ }
724
+ else {
725
+ setProjectError(r.error || 'Could not attach that directory.');
726
+ }
727
+ }, []);
728
+ // P4b β€” keep the ref in sync so resumeSession can call attachProject without a direct dependency.
729
+ attachProjectRef.current = attachProject;
730
+ /** Browse directories for the folder picker. */
731
+ const openBrowse = (0, react_1.useCallback)(async (startPath) => {
732
+ setBrowseOpen(true);
733
+ setBrowseLoading(true);
734
+ setBrowseFilter('');
735
+ const r = await api_1.dashboardAPI.browseDirectories(startPath, { showDrives: !startPath });
736
+ if (r.ok) {
737
+ setBrowsePath(r.path);
738
+ setBrowseEntries(r.entries);
739
+ setBrowseParent(r.parent);
740
+ if (r.drives)
741
+ setBrowseDrives(r.drives);
742
+ if (r.breadcrumbs)
743
+ setBrowseBreadcrumbs(r.breadcrumbs);
744
+ }
745
+ setBrowseLoading(false);
746
+ }, []);
747
+ const browseTo = (0, react_1.useCallback)(async (dirPath) => {
748
+ setBrowseLoading(true);
749
+ setBrowseFilter('');
750
+ const r = await api_1.dashboardAPI.browseDirectories(dirPath);
751
+ if (r.ok) {
752
+ setBrowsePath(r.path);
753
+ setBrowseEntries(r.entries);
754
+ setBrowseParent(r.parent);
755
+ if (r.breadcrumbs)
756
+ setBrowseBreadcrumbs(r.breadcrumbs);
757
+ }
758
+ setBrowseLoading(false);
759
+ }, []);
760
+ /** Refresh the current browse directory (auto-refresh or manual). */
761
+ const refreshBrowse = (0, react_1.useCallback)(async () => {
762
+ if (!browsePath)
763
+ return;
764
+ const r = await api_1.dashboardAPI.browseDirectories(browsePath);
765
+ if (r.ok) {
766
+ setBrowseEntries(r.entries);
767
+ }
768
+ }, [browsePath]);
769
+ /** Auto-refresh browse every 5 seconds when open. */
770
+ (0, react_1.useEffect)(() => {
771
+ if (browseOpen && browsePath) {
772
+ browseRefreshRef.current = setInterval(() => { void refreshBrowse(); }, 5000);
773
+ }
774
+ return () => {
775
+ if (browseRefreshRef.current)
776
+ clearInterval(browseRefreshRef.current);
777
+ };
778
+ }, [browseOpen, browsePath, refreshBrowse]);
779
+ /** Native folder picker using showDirectoryPicker (File System Access API).
780
+ * Shows a native "Select Folder" dialog β€” no "Upload" text, no file count.
781
+ * Falls back to the custom popover on unsupported browsers (Firefox, Safari). */
782
+ const openNativeFolderPicker = (0, react_1.useCallback)(async () => {
783
+ // Try the modern File System Access API first (Chrome/Edge 102+).
784
+ // It shows a real "Select Folder" dialog with no confusing upload messaging.
785
+ const hasDirectoryPicker = typeof window !== 'undefined' && 'showDirectoryPicker' in window;
786
+ if (hasDirectoryPicker) {
787
+ try {
788
+ const dirHandle = await window.showDirectoryPicker({ mode: 'read' });
789
+ const folderName = dirHandle.name;
790
+ if (!folderName) {
791
+ void openBrowse();
792
+ return;
793
+ }
794
+ setProjectError('');
795
+ setBusy(true);
796
+ // Server searches common locations for this folder name.
797
+ // If exactly one match β†’ attach directly. If multiple β†’ show chooser.
798
+ const r = await api_1.dashboardAPI.resolveFolder(folderName);
799
+ setBusy(false);
800
+ if (r.ok && r.path) {
801
+ setProjectPathInput(r.path);
802
+ void attachProject(r.path);
803
+ }
804
+ else {
805
+ // Could not find automatically β€” let user type the path or use manual browser.
806
+ setProjectError('Folder "' + folderName + '" found β€” selecting it now. If this is wrong, type the full path below.');
807
+ }
808
+ return;
809
+ }
810
+ catch (err) {
811
+ // User cancelled or API not supported β€” fall through to manual browser.
812
+ if (err?.name === 'AbortError')
813
+ return; // user cancelled β€” do nothing
814
+ }
815
+ }
816
+ // Fallback: open the custom in-page folder browser.
817
+ void openBrowse();
818
+ }, [openBrowse, attachProject]);
819
+ const send = (0, react_1.useCallback)(async (text, withAttachments) => {
820
+ const clean = text.trim();
821
+ const chipList = Array.isArray(withAttachments) ? withAttachments : attachments;
822
+ const skipResolve = !Array.isArray(withAttachments) && withAttachments?.skipResolve === true;
823
+ if ((!clean && chipList.length === 0) || busy)
824
+ return;
825
+ setError('');
826
+ setMeta(null);
827
+ setLiveSteps([]);
828
+ liveStepsRef.current = [];
829
+ setLiveTools([]);
830
+ liveToolsRef.current = [];
831
+ setLivePlan(null);
832
+ livePlanRef.current = null;
833
+ setLiveDiff(null);
834
+ liveDiffRef.current = null;
835
+ setLiveDraft(null);
836
+ liveDraftRef.current = null;
837
+ setMessages((m) => [...m, { role: 'user', content: clean, attachments: chipList.length > 0 ? chipList : undefined }]);
838
+ setInput('');
839
+ setAttachments([]);
840
+ setPasteOffer(null);
841
+ lastSentRef.current = clean || `[${chipList.length} attachment(s)]`;
842
+ setBusy(true);
843
+ // P8 β€” smart rail: while the agent works, the results own the window.
844
+ setRailOpen(false);
845
+ // Pre-resolve the ask against the command manifest. A confident match
846
+ // short-circuits to a confirm card (deterministic commands like "stop
847
+ // the dashboard" shouldn't need a model turn); ambiguous asks show
848
+ // their options as choices; everything else falls through to the agent.
849
+ // skipResolve: when the user already declined a resolved command
850
+ // ("No β€” ask the agent"), skip re-resolution and go straight to the
851
+ // agent β€” prevents the loop where declining re-shows the same card.
852
+ if (!skipResolve) {
853
+ const resolved = await api_1.dashboardAPI.chatResolve(clean);
854
+ const matches = (resolved.matches ?? []);
855
+ const top = matches[0] ?? null;
856
+ if (top && top.command && !top.ambiguous && top.score >= 0.6) {
857
+ setPendingResolve({ ask: clean, top });
858
+ setBusy(false);
859
+ return;
860
+ }
861
+ if (top && top.ambiguous && !pendingResolve) {
862
+ setPendingResolve({ ask: clean, top });
863
+ setBusy(false);
864
+ return;
865
+ }
866
+ }
867
+ const sessionId = sessionIdRef.current;
868
+ // P4 β€” arm the abort controller for this turn (the Cancel button fires
869
+ // it; the server cancels the turn when the aborted fetch closes).
870
+ const controller = new AbortController();
871
+ abortRef.current = controller;
872
+ setRetryAsk(null);
873
+ // Subscribe to LIVE progress BEFORE the turn starts so no step is missed
874
+ // (EventSource auto-reconnects; the final answer arrives via the POST).
875
+ subRef.current?.();
876
+ subRef.current = api_1.dashboardAPI.subscribeChat(sessionId, {
877
+ // P4 β€” answer tokens typewrite into the live bubble; the POST response
878
+ // replaces them with the authoritative final content.
879
+ onToken: (text) => {
880
+ streamingRef.current += text;
881
+ setStreamingText(streamingRef.current);
882
+ },
883
+ onProgress: (line) => {
884
+ liveStepsRef.current = [...liveStepsRef.current, line];
885
+ setLiveSteps(liveStepsRef.current);
886
+ },
887
+ // P0.1 β€” the agent asked a clarifying question; show the choice card.
888
+ onQuestion: (q) => {
889
+ setPendingQuestion(q);
890
+ setQuestionSel(new Set());
891
+ },
892
+ // P0.6 β€” live tool card: `started` creates/updates the card, `called`
893
+ // completes it with ok/error + duration. Keyed by the stable call id.
894
+ onTool: (t) => {
895
+ const next = [...liveToolsRef.current];
896
+ const idx = next.findIndex((c) => c.id === t.id);
897
+ const card = { id: t.id, tool: t.tool, phase: t.phase, args: t.args, ok: t.ok, result: t.result, error: t.error, durationMs: t.durationMs };
898
+ if (idx >= 0)
899
+ next[idx] = card;
900
+ else
901
+ next.push(card);
902
+ liveToolsRef.current = next;
903
+ setLiveTools(next);
904
+ },
905
+ // P0.7 β€” live checklist: each plan_todo mutation replaces the card
906
+ // (revision-ordered, in place). Snapshot into the final message.
907
+ onPlan: (p) => {
908
+ const view = { goal: p.goal, steps: p.steps, revision: p.revision };
909
+ livePlanRef.current = view;
910
+ setLivePlan(view);
911
+ },
912
+ // P3b β€” the git tool emitted a diff; render it as a card (latest wins
913
+ // β€” a turn may diff several times, each replaces the card).
914
+ onDiff: (d) => {
915
+ const view = { files: d.files, summary: d.summary };
916
+ liveDiffRef.current = view;
917
+ setLiveDiff(view);
918
+ },
919
+ // P6a β€” skill_manage create/patch emitted a draft; render the /learn
920
+ // preview card (accept saves, edit re-drafts, reject discards).
921
+ onSkillDraft: (d) => {
922
+ const view = { name: d.name, description: d.description, markdown: d.markdown, updatedAt: d.updatedAt, status: 'pending' };
923
+ liveDraftRef.current = view;
924
+ setLiveDraft(view);
925
+ },
926
+ // PA4 β€” a skill loaded but needs env vars; show notification card.
927
+ onSecretRequest: (d) => {
928
+ setSecretRequests((prev) => {
929
+ const idx = prev.findIndex((r) => r.skillName === d.skillName);
930
+ if (idx >= 0) {
931
+ const next = [...prev];
932
+ next[idx] = d;
933
+ return next;
934
+ }
935
+ return [...prev, d];
936
+ });
937
+ },
938
+ // Execution result from skill execution engine.
939
+ onExecutionResult: (d) => {
940
+ setExecutionResults((prev) => [...prev, d]);
941
+ },
942
+ });
943
+ const r = await api_1.dashboardAPI.chatSend(sessionId, clean || `(see ${chipList.length} attachment${chipList.length === 1 ? '' : 's'})`, {
944
+ projectPath: attachedProject?.path,
945
+ attachments: chipList.map((c) => ({ name: c.name, content: c.content, kind: c.kind })),
946
+ }, controller.signal);
947
+ abortRef.current = null;
948
+ subRef.current?.();
949
+ subRef.current = null;
950
+ // P4 β€” the turn resolved: the streamed typewriter is replaced by the
951
+ // authoritative content (which the final message below renders).
952
+ streamingRef.current = '';
953
+ setStreamingText('');
954
+ if (r.ok) {
955
+ setMeta(r.generationFailed ? null : `${r.provider ?? 'provider'}${r.model ? ` / ${r.model}` : ' (auto-routed)'}`);
956
+ // P4 β€” a failed generation (no usable answer) offers Retry too.
957
+ setRetryAsk(r.generationFailed ? clean : null);
958
+ const replyContent = r.content || '(the agent produced no text β€” try rephrasing)';
959
+ setMessages((m) => [
960
+ ...m,
961
+ {
962
+ role: 'assistant',
963
+ content: replyContent,
964
+ error: r.generationFailed,
965
+ followups: r.followups,
966
+ steps: liveStepsRef.current,
967
+ tools: liveToolsRef.current,
968
+ plan: livePlanRef.current,
969
+ diff: liveDiffRef.current,
970
+ draft: liveDraftRef.current,
971
+ // P2 β€” extract artifact cards from the answer TEXT (diff/result/
972
+ // deploy blocks the model wrote directly, beyond the live events).
973
+ artifacts: (0, artifacts_1.extractArtifacts)(replyContent),
974
+ },
975
+ ]);
976
+ }
977
+ else {
978
+ if (controller.signal.aborted) {
979
+ // P4 β€” the user pressed Cancel: silent cleanup. The user bubble stays
980
+ // (the message was sent), no error banner, nothing persisted
981
+ // server-side (the console discards cancelled turns).
982
+ setError('');
983
+ setRetryAsk(null);
984
+ }
985
+ else if (r.unauthorized) {
986
+ setAuth((a) => (a ? { ...a, authenticated: false } : a));
987
+ setError('Session expired β€” log in again to chat.');
988
+ }
989
+ else {
990
+ setError(r.error || 'The agent could not answer β€” check that a provider API key is set for the dashboard process.');
991
+ // P4 β€” retry on failed turn: keep the user bubble and offer to
992
+ // re-send the same message (previous behavior dropped it).
993
+ setRetryAsk(clean);
994
+ }
995
+ }
996
+ setBusy(false);
997
+ // P8 β€” the turn resolved: bring the history rail back.
998
+ setRailOpen(true);
999
+ }, [busy, attachedProject, attachments]);
1000
+ /** P4 β€” cancel the in-flight turn (aborts the POST; the server cancels it). */
1001
+ const cancelTurn = (0, react_1.useCallback)(() => {
1002
+ abortRef.current?.abort();
1003
+ }, []);
1004
+ /**
1005
+ * P6a β€” the preview card's actions:
1006
+ * βœ… accept β†’ POST /api/skills/drafts/<name>/accept (promotes to live).
1007
+ * ↩ reject β†’ DELETE the draft (discarded, nothing saved).
1008
+ * ✏️ edit β†’ send a chat turn asking the agent to revise the draft (the
1009
+ * agent re-drafts via skill_manage create β†’ a fresh card).
1010
+ */
1011
+ const acceptDraft = (0, react_1.useCallback)(async (name) => {
1012
+ setLiveDraft((d) => (d ? { ...d, status: 'saving' } : d));
1013
+ const r = await api_1.dashboardAPI.skillDraftAccept(name);
1014
+ setLiveDraft((d) => (d ? { ...d, status: r.ok ? 'saved' : 'error' } : d));
1015
+ if (!r.ok)
1016
+ setError(r.error || 'Could not accept the draft.');
1017
+ }, []);
1018
+ const rejectDraft = (0, react_1.useCallback)(async (name) => {
1019
+ const r = await api_1.dashboardAPI.skillDraftReject(name);
1020
+ if (r.ok) {
1021
+ setLiveDraft((d) => (d ? { ...d, status: 'rejected' } : d));
1022
+ }
1023
+ else {
1024
+ setError(r.error || 'Could not reject the draft.');
1025
+ }
1026
+ }, []);
1027
+ const editDraft = (0, react_1.useCallback)((name) => {
1028
+ // Ask the agent to revise β€” the draft is still pending; the agent's next
1029
+ // skill_manage create/patch emits an updated preview card.
1030
+ void send(`Revise the skill draft "${name}" β€” improve it per your best judgment and present it again.`);
1031
+ }, [send]);
1032
+ /** PA4 β€” save skill env vars from the secret request card. */
1033
+ const saveSecrets = (0, react_1.useCallback)(async (vars) => {
1034
+ const r = await api_1.dashboardAPI.saveSecrets(vars);
1035
+ if (!r.ok)
1036
+ setError(r.error || 'Could not save secrets.');
1037
+ }, []);
1038
+ /**
1039
+ * Run the resolved CLI command directly (the user confirmed the card).
1040
+ * P2 β€” instead of polling then appending plain text, this renders a LIVE
1041
+ * execution card in the thread (command, streamed logs, status, exit code,
1042
+ * cancel) via the same P1 task runner + SSE the Command Console uses.
1043
+ */
1044
+ const runResolvedCommand = (0, react_1.useCallback)(async (ask) => {
1045
+ const resolved = await api_1.dashboardAPI.chatResolve(ask);
1046
+ const matches = (resolved.matches ?? []);
1047
+ const top = matches[0] ?? null;
1048
+ if (!top?.command) {
1049
+ setPendingResolve(null);
1050
+ return;
1051
+ }
1052
+ setPendingResolve(null);
1053
+ setBusy(true);
1054
+ // Execute via the real CLI as a task (same runner as the Command Console)
1055
+ // β€” deterministic, no model turn, RBAC-gated by the dashboard session.
1056
+ const argv = top.command.split(/\s+/);
1057
+ const started = await api_1.dashboardAPI.startTask(argv, 60_000);
1058
+ if (!started.ok || !started.task) {
1059
+ setError(started.error || 'The command could not be started.');
1060
+ setBusy(false);
1061
+ return;
1062
+ }
1063
+ const id = started.task.id;
1064
+ const initial = {
1065
+ id,
1066
+ command: top.command,
1067
+ status: started.task.status,
1068
+ exitCode: started.task.exitCode,
1069
+ durationMs: started.task.durationMs,
1070
+ logs: started.task.logs ?? [],
1071
+ };
1072
+ // Insert the execution card into the thread; it updates in place as
1073
+ // log/status events stream in (patched by matching task id).
1074
+ setMessages((m) => [...m, { role: 'assistant', content: '', task: initial }]);
1075
+ const patch = (p) => setMessages((m) => m.map((msg) => (msg.task && msg.task.id === id ? { ...msg, task: { ...msg.task, ...p } } : msg)));
1076
+ let unsub = null;
1077
+ unsub = api_1.dashboardAPI.subscribeTask(id, {
1078
+ onLog: (line) => setMessages((m) => m.map((msg) => (msg.task && msg.task.id === id ? { ...msg, task: { ...msg.task, logs: [...msg.task.logs, line] } } : msg))),
1079
+ onStatus: (status) => {
1080
+ patch({ status });
1081
+ if (status !== 'running') {
1082
+ // The status event carries no exit code/duration β€” grab one final
1083
+ // snapshot so the settled card shows them.
1084
+ void api_1.dashboardAPI.getTask(id).then((t) => {
1085
+ if (t?.task)
1086
+ patch({ exitCode: t.task.exitCode, durationMs: t.task.durationMs, logs: t.task.logs ?? [] });
1087
+ });
1088
+ unsub?.();
1089
+ setBusy(false);
1090
+ }
1091
+ },
1092
+ });
1093
+ // Seed with the full log snapshot (the start response may lag the run).
1094
+ const init = await api_1.dashboardAPI.getTask(id);
1095
+ if (init?.task) {
1096
+ patch({ status: init.task.status, exitCode: init.task.exitCode, durationMs: init.task.durationMs, logs: init.task.logs ?? [] });
1097
+ }
1098
+ // The task may already have settled (short command) β€” close the stream.
1099
+ const settled = await api_1.dashboardAPI.getTask(id);
1100
+ if (settled?.task && settled.task.status !== 'running') {
1101
+ unsub?.();
1102
+ patch({ status: settled.task.status, exitCode: settled.task.exitCode, durationMs: settled.task.durationMs, logs: settled.task.logs ?? [] });
1103
+ setBusy(false);
1104
+ }
1105
+ }, []);
1106
+ /** User declined the command card β€” ask the agent normally instead. */
1107
+ const declineResolvedCommand = (0, react_1.useCallback)((ask) => {
1108
+ setPendingResolve(null);
1109
+ void send(ask, { skipResolve: true });
1110
+ }, [send]);
1111
+ /**
1112
+ * P2 β€” the diff card's "Commit accepted" action. Sends the accepted file
1113
+ * subset back as a chat turn; the agent commits exactly those files via the
1114
+ * git tool's accepted-subset contract (commit with files=[...], re-confirmed
1115
+ * through ask_user) β€” the dashboard never re-implements diff application.
1116
+ */
1117
+ const commitAcceptedDiff = (0, react_1.useCallback)((paths) => {
1118
+ if (paths.length === 0)
1119
+ return;
1120
+ // P2 β€” extracted text diffs are only selectable with an attached
1121
+ // project; name that project so the agent commits in ITS working tree
1122
+ // (not the dashboard's cwd). The engine's git tool runs in ctx.cwd.
1123
+ const where = attachedProject ? ` in the attached project ${attachedProject.path}` : '';
1124
+ void send(`Commit exactly these files that I accepted on the diff card (and nothing else)${where}: ${paths.join(', ')}. ` +
1125
+ `Show me a short confirmation before finishing.`);
1126
+ }, [send, attachedProject]);
1127
+ /** P2 β€” cancel a running inline command-run card. */
1128
+ const cancelTaskRun = (0, react_1.useCallback)(async (id) => {
1129
+ await api_1.dashboardAPI.cancelTask(id);
1130
+ }, []);
1131
+ /** P0.1 β€” submit the agent's clarifying-question answer; the turn resumes. */
1132
+ const answerQuestion = (0, react_1.useCallback)(async (selection) => {
1133
+ const q = pendingQuestion;
1134
+ if (!q)
1135
+ return;
1136
+ setPendingQuestion(null);
1137
+ const r = await api_1.dashboardAPI.chatRespond(sessionIdRef.current, q.questionId, selection);
1138
+ if (!r.ok) {
1139
+ setError(r.error || 'The question could not be answered β€” try sending your message again.');
1140
+ }
1141
+ }, [pendingQuestion]);
1142
+ const submitQuestion = (0, react_1.useCallback)(() => {
1143
+ if (!pendingQuestion)
1144
+ return;
1145
+ const idx = pendingQuestion.multiSelect ? [...questionSel] : [...questionSel][0];
1146
+ if (pendingQuestion.multiSelect) {
1147
+ void answerQuestion({ index: [...questionSel] });
1148
+ }
1149
+ else if (idx !== undefined) {
1150
+ void answerQuestion({ index: idx });
1151
+ }
1152
+ else {
1153
+ // No selection β€” skip (agent proceeds on best judgment).
1154
+ void answerQuestion({ index: -1 });
1155
+ }
1156
+ }, [pendingQuestion, questionSel, answerQuestion]);
1157
+ const skipQuestion = (0, react_1.useCallback)(() => {
1158
+ if (!pendingQuestion)
1159
+ return;
1160
+ void answerQuestion({ index: -1 });
1161
+ }, [pendingQuestion, answerQuestion]);
1162
+ const toggleQuestionChoice = (0, react_1.useCallback)((i) => {
1163
+ setQuestionSel((prev) => {
1164
+ const next = new Set(prev);
1165
+ if (pendingQuestion?.multiSelect) {
1166
+ if (next.has(i))
1167
+ next.delete(i);
1168
+ else
1169
+ next.add(i);
1170
+ }
1171
+ else {
1172
+ next.clear();
1173
+ next.add(i);
1174
+ }
1175
+ return next;
1176
+ });
1177
+ }, [pendingQuestion]);
1178
+ const resetConversation = (0, react_1.useCallback)(async () => {
1179
+ // P4 β€” "New conversation" starts a fresh id; the CURRENT thread stays
1180
+ // persisted server-side and appears in the sidebar (resumable).
1181
+ subRef.current?.();
1182
+ subRef.current = null;
1183
+ sessionIdRef.current = newSessionId();
1184
+ setMessages([]);
1185
+ setLiveSteps([]);
1186
+ liveStepsRef.current = [];
1187
+ setLiveTools([]);
1188
+ liveToolsRef.current = [];
1189
+ setLivePlan(null);
1190
+ livePlanRef.current = null;
1191
+ setLiveDiff(null);
1192
+ liveDiffRef.current = null;
1193
+ setLiveDraft(null);
1194
+ liveDraftRef.current = null;
1195
+ streamingRef.current = '';
1196
+ setStreamingText('');
1197
+ setRetryAsk(null);
1198
+ setError('');
1199
+ setMeta(null);
1200
+ setPendingResolve(null);
1201
+ setPendingQuestion(null);
1202
+ // Refresh the sidebar (the just-abandoned session is now in it).
1203
+ const s = await api_1.dashboardAPI.listChatSessions();
1204
+ if (Array.isArray(s))
1205
+ setSessions(s);
1206
+ }, []);
1207
+ const latestFollowups = [...messages].reverse().find((m) => m.role === 'assistant' && !m.error && (m.followups?.length ?? 0) > 0)?.followups ?? [];
1208
+ return (<div className="panel">
1209
+ <div className="panel-header">
1210
+ <h2>πŸ’¬ Chat with the agent</h2>
1211
+ <div className="chat-head-actions">
1212
+ {meta ? <span className="admin-hint">{meta}</span> : null}
1213
+ <button className="admin-refresh-btn" type="button" onClick={() => void resetConversation()} disabled={busy || messages.length === 0}>
1214
+ πŸ—‘ New conversation
1215
+ </button>
1216
+ </div>
1217
+ </div>
1218
+
1219
+ {!auth?.authenticated ? (<div className="admin-login-hint">
1220
+ <p>Log in (admin or operator) to chat with the agent. Configure the dashboard admin credential first if this is a fresh setup.</p>
1221
+ {auth?.configured === false ? (<p className="admin-hint">
1222
+ Run <code>buff dashboard</code> once, or set <code>BUFF_DASHBOARD_ADMIN_USER</code> / <code>BUFF_DASHBOARD_ADMIN_PASSWORD</code>.
1223
+ </p>) : null}
1224
+ </div>) : !canChat ? (<div className="admin-login-hint">
1225
+ <p>Your role can view the dashboard but not chat (requires admin or operator).</p>
1226
+ </div>) : (<>
1227
+ <div className="chat-project-bar">
1228
+ {attachedProject ? (<div className="chat-project-attached">
1229
+ <span className="chat-project-icon">πŸ“</span>
1230
+ <span className="chat-project-name">{attachedProject.name}</span>
1231
+ <span className="chat-project-meta">
1232
+ {attachedProject.fileCount} files Β· {attachedProject.symbolCount} symbols{attachedProject.truncated ? ' Β· truncated map' : ''}
1233
+ </span>
1234
+ <span className="chat-project-path" title={attachedProject.path}>{attachedProject.path}</span>
1235
+ <button type="button" className="admin-mini-btn" onClick={() => setAttachedProject(null)}>βœ• detach</button>
1236
+ </div>) : (<div className="chat-project-pick">
1237
+ <span className="chat-project-icon">πŸ“</span>
1238
+ <span className="chat-project-hint">Select Project Folder</span>
1239
+ {projectPick.length > 0 ? (<span className="chat-project-chips">
1240
+ {projectPick.filter((p) => p.kind !== 'cwd').slice(0, 3).map((p) => (<button key={p.path} type="button" className="chat-chip" onClick={() => void attachProject(p.path)}>
1241
+ {p.name}
1242
+ </button>))}
1243
+ </span>) : null}
1244
+ <input className="chat-project-input" value={projectPathInput} onChange={(e) => setProjectPathInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') {
1245
+ e.preventDefault();
1246
+ void attachProject(projectPathInput);
1247
+ } }} placeholder="or type a path, e.g. ~/code/my-app" disabled={busy}/>
1248
+ <button type="button" className="admin-refresh-btn" onClick={() => void attachProject(projectPathInput)} disabled={busy || !projectPathInput.trim()}>
1249
+ Attach
1250
+ </button>
1251
+ <button type="button" className="admin-mini-btn" onClick={() => void openNativeFolderPicker()} title="Pick a project folder using your system's folder picker (Finder / Explorer)">
1252
+ πŸ—‚οΈ Browse
1253
+ </button>
1254
+ {projectError ? <span className="chat-project-error">{projectError}</span> : null}
1255
+ {browseOpen ? (<div className="chat-browse-popover">
1256
+ <div className="chat-browse-head">
1257
+ <button type="button" className="admin-mini-btn" onClick={() => setBrowseOpen(false)} title="Close">βœ•</button>
1258
+ {/* Breadcrumbs */}
1259
+ <div className="chat-browse-breadcrumbs">
1260
+ {browseDrives.length > 0 && !browsePath ? (<span className="admin-hint">Select a drive or home folder:</span>) : (<>
1261
+ <button type="button" className="chat-breadcrumb" onClick={() => void openBrowse()}>🏠</button>
1262
+ {browseBreadcrumbs.map((b, i) => (<span key={b.path}>
1263
+ <span className="chat-breadcrumb-sep">/</span>
1264
+ <button type="button" className="chat-breadcrumb" onClick={() => void browseTo(b.path)}>{b.name}</button>
1265
+ </span>))}
1266
+ </>)}
1267
+ </div>
1268
+ </div>
1269
+ {/* Drive bar (Windows / Mac) */}
1270
+ {browseDrives.length > 0 && !browsePath ? (<div className="chat-browse-drives">
1271
+ {browseDrives.map((d) => (<button key={d.path} type="button" className="chat-browse-drive" onClick={() => void browseTo(d.path)}>
1272
+ {d.name}
1273
+ </button>))}
1274
+ </div>) : null}
1275
+ {/* Search filter */}
1276
+ {browseEntries.length > 5 ? (<div className="chat-browse-search">
1277
+ <input type="text" className="chat-browse-filter" placeholder="πŸ” Filter folders…" value={browseFilter} onChange={(e) => setBrowseFilter(e.target.value)} autoFocus/>
1278
+ </div>) : null}
1279
+ {/* Folder list */}
1280
+ <div className="chat-browse-list">
1281
+ {browseLoading ? (<span className="admin-hint">Loading…</span>) : browseEntries.length === 0 ? (<span className="admin-hint">No subdirectories found</span>) : (browseEntries
1282
+ .filter((e) => !browseFilter || e.name.toLowerCase().includes(browseFilter.toLowerCase()))
1283
+ .map((e) => (<button key={e.path} type="button" className="chat-browse-entry" onClick={() => void browseTo(e.path)}>
1284
+ <span className="chat-browse-entry-icon">πŸ“</span>
1285
+ <span className="chat-browse-entry-name">{e.name}</span>
1286
+ {e.modified ? (<span className="chat-browse-entry-date">
1287
+ {new Date(e.modified).toLocaleDateString()}
1288
+ </span>) : null}
1289
+ </button>)))}
1290
+ </div>
1291
+ <div className="chat-browse-foot">
1292
+ <button type="button" className="admin-mini-btn" onClick={() => void refreshBrowse()} title="Refresh folder list">
1293
+ πŸ”„
1294
+ </button>
1295
+ <button type="button" className="admin-refresh-btn" onClick={() => { setBrowseOpen(false); void attachProject(browsePath); }} disabled={!browsePath}>
1296
+ πŸ“ Select this folder
1297
+ </button>
1298
+ </div>
1299
+ </div>) : null}
1300
+ </div>)}
1301
+ </div>
1302
+ {/* P4b β€” show a banner when the resumed session's project doesn't match the attached project. */}
1303
+ {sessionProjectPath && (!attachedProject || attachedProject.path !== sessionProjectPath) ? (<div className="chat-session-project-banner">
1304
+ <span>⚠️ This conversation was working in <code>{sessionProjectPath}</code></span>
1305
+ {!attachedProject ? (<button type="button" className="admin-refresh-btn" onClick={() => void attachProject(sessionProjectPath)}>
1306
+ πŸ“ Attach it
1307
+ </button>) : (<button type="button" className="admin-refresh-btn" onClick={() => void attachProject(sessionProjectPath)}>
1308
+ πŸ“ Switch to it
1309
+ </button>)}
1310
+ </div>) : null}
1311
+ <div className="chat-layout">
1312
+ {!railOpen ? (<div className="chat-rail">
1313
+ <button type="button" className="chat-rail-btn" onClick={() => setRailOpen(true)} title="Show history">
1314
+ πŸ“
1315
+ </button>
1316
+ </div>) : (<div className="chat-sidebar">
1317
+ <div className="chat-sidebar-head">
1318
+ <span>πŸ“ Sessions</span>
1319
+ <div className="chat-sidebar-head-actions">
1320
+ <button type="button" className="chat-mini-action" title="New chat" onClick={newChat}>οΌ‹ New</button>
1321
+ <button type="button" className="chat-mini-action" title="Collapse history" onClick={() => setRailOpen(false)}>β–Έ</button>
1322
+ </div>
1323
+ </div>
1324
+ <input className="chat-session-search" placeholder="Search conversations…" value={sessionQuery} onChange={(e) => setSessionQuery(e.target.value)}/>
1325
+ {sessions.length === 0 ? (<div className="chat-sidebar-empty">No past conversations yet.</div>) : (<div className="chat-sidebar-list">
1326
+ {groupSessions(sessions, sessionQuery).map(({ label, items }) => (<div key={label} className="chat-session-group">
1327
+ <div className="chat-session-group-label">{label}</div>
1328
+ {items.map((s) => (<div key={s.id} className={`chat-session-item${s.id === sessionIdRef.current ? ' chat-session-active' : ''}`}>
1329
+ {renamingId === s.id ? (<div className="chat-session-rename">
1330
+ <input autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)} onKeyDown={(e) => {
1331
+ if (e.key === 'Enter')
1332
+ void commitRename(s.id);
1333
+ if (e.key === 'Escape')
1334
+ setRenamingId(null);
1335
+ }} placeholder="Session title"/>
1336
+ <button type="button" className="chat-mini-action" onClick={() => void commitRename(s.id)}>βœ“</button>
1337
+ </div>) : (<>
1338
+ <button type="button" className="chat-session-main" onClick={() => void resumeSession(s.id)} title={s.preview || s.title}>
1339
+ <span className="chat-session-title">{s.title}</span>
1340
+ <span className="chat-session-preview">{s.firstUser || s.preview}</span>
1341
+ <span className="chat-session-meta">
1342
+ {s.projectPath ? `πŸ“ ${s.projectPath.split('/').pop()} Β· ` : ''}{s.turnCount} msg{s.turnCount === 1 ? '' : 's'} Β· {new Date(s.updatedAt).toLocaleString()}
1343
+ </span>
1344
+ </button>
1345
+ <span className="chat-session-actions">
1346
+ <button type="button" className="chat-mini-action" title="Rename" onClick={() => startRename(s)}>✏️</button>
1347
+ <button type="button" className="chat-mini-action" title="Delete" onClick={() => void deleteSession(s.id)}>πŸ—‘</button>
1348
+ </span>
1349
+ </>)}
1350
+ </div>))}
1351
+ </div>))}
1352
+ </div>)}
1353
+ </div>)}
1354
+ <div className="chat-main">
1355
+ <div className="chat-thread" ref={listRef} role="log" aria-live="polite">
1356
+ {messages.length === 0 ? (<div className="empty-state">
1357
+ <p className="empty-state-title">Say anything β€” the agent decides what to do (answer, fix code, plan, run the pipeline).</p>
1358
+ <div className="empty-state-chips">
1359
+ <button type="button" className="chat-chip" onClick={() => void send("what's the state of this project?")}>
1360
+ πŸ“‹ assess this project
1361
+ </button>
1362
+ <button type="button" className="chat-chip" onClick={() => void send('run the test suite')}>
1363
+ πŸ§ͺ run the tests
1364
+ </button>
1365
+ <button type="button" className="chat-chip" onClick={() => void send('stop the gateway')}>
1366
+ ⏹ stop the gateway
1367
+ </button>
1368
+ {/* P6e β€” the shipable first-party batch is the entry point: a
1369
+ new user sees the skill suggestions in the empty state. */}
1370
+ <button type="button" className="chat-chip" onClick={() => void send('load the code-assessment skill and assess this project')}>
1371
+ 🧠 load the code-assessment skill
1372
+ </button>
1373
+ <button type="button" className="chat-chip" onClick={() => void send('learn the workflow I just did as a skill')}>
1374
+ πŸ“š learn a workflow as a skill
1375
+ </button>
1376
+ <button type="button" className="chat-chip" onClick={() => void send('publish the current version')}>
1377
+ πŸš€ publish the release
1378
+ </button>
1379
+ </div>
1380
+ </div>) : (messages.map((m, i) => (<div key={i} className={`chat-bubble chat-${m.role}${m.error ? ' chat-error' : ''}`}>
1381
+ <div className="chat-bubble-role">{m.role === 'user' ? 'You' : 'πŸ€– Agent'}</div>
1382
+ {m.role === 'assistant' ? (<div className="chat-bubble-text"><Markdown_1.default text={m.content}/></div>) : (<div className="chat-bubble-text">{m.content}</div>)}
1383
+ {m.role === 'user' && m.attachments && m.attachments.length > 0 ? (<details className="chat-steps" open={m.attachments.length === 1}>
1384
+ <summary>
1385
+ πŸ“Ž {m.attachments.length} attachment{m.attachments.length === 1 ? '' : 's'}:{' '}
1386
+ {m.attachments.map((a) => a.name).join(', ')}
1387
+ </summary>
1388
+ {m.attachments.map((a, ai) => (<div key={ai} className="chat-attach-content">
1389
+ <div className="admin-hint">{a.name} β€” {a.content.length.toLocaleString()} chars</div>
1390
+ <pre>{a.content}</pre>
1391
+ </div>))}
1392
+ </details>) : null}
1393
+ {m.role === 'assistant' && m.diff ? (<details className="chat-steps" open>
1394
+ <summary>Changes: {m.diff.summary}</summary>
1395
+ {/* P2 β€” the snapshotted git diff is SELECTABLE: per-file
1396
+ accept/reject + "Commit accepted" (the engine commits
1397
+ only the accepted subset). */}
1398
+ <DiffCard diff={m.diff} selectable onCommitAccepted={commitAcceptedDiff}/>
1399
+ </details>) : null}
1400
+ {m.role === 'assistant' && m.artifacts && (m.artifacts.diffs.length > 0 || m.artifacts.results.length > 0 || m.artifacts.deploys.length > 0) ? (
1401
+ // P2 β€” the artifact stack is a keyboard-navigable list:
1402
+ // ↑/↓ moves between cards, Home/End jumps to the ends.
1403
+ <ArtifactNav>
1404
+ {m.artifacts.diffs.map((d, di) => (<div key={`diff-${di}`} data-artifact-card tabIndex={0} role="group" aria-label={`Diff: ${d.summary}`} className="chat-artifact-item">
1405
+ {/* P2 β€” extracted TEXT diffs are selectable only with
1406
+ an attached project: the diff plausibly refers to
1407
+ that working tree. Without one, the diff is prose
1408
+ with no known repo β€” read-only + a hint, so a
1409
+ stray ```diff block can never trigger a commit. */}
1410
+ {attachedProject ? (<DiffCard diff={d} selectable onCommitAccepted={commitAcceptedDiff}/>) : (<DiffCard diff={d} lockedHint="Attach a project to review and commit these changes."/>)}
1411
+ </div>))}
1412
+ {m.artifacts.results.map((r, ri) => (<div key={`result-${ri}`} data-artifact-card tabIndex={0} role="group" aria-label={`Result: ${r.title}`} className="chat-artifact-item">
1413
+ <ResultCard result={r}/>
1414
+ </div>))}
1415
+ {m.artifacts.deploys.map((d, di) => (<div key={`deploy-${di}`} data-artifact-card tabIndex={0} role="group" aria-label={`Deployment: ${d.url}`} className="chat-artifact-item">
1416
+ <DeployCard deploy={d}/>
1417
+ </div>))}
1418
+ </ArtifactNav>) : null}
1419
+ {m.role === 'assistant' && m.task ? (<TaskRunCard task={m.task} onCancel={m.task.status === 'running' ? () => void cancelTaskRun(m.task.id) : undefined}/>) : null}
1420
+ {m.role === 'assistant' && m.draft ? (<details className="chat-steps" open>
1421
+ <summary>Skill draft: {m.draft.name}</summary>
1422
+ <SkillDraftCard draft={m.draft} onAccept={acceptDraft} onReject={rejectDraft} onEdit={editDraft}/>
1423
+ </details>) : null}
1424
+ {m.role === 'assistant' && m.plan ? (<details className="chat-steps" open>
1425
+ <summary>Plan: {m.plan.goal}</summary>
1426
+ <PlanCard plan={m.plan}/>
1427
+ </details>) : null}
1428
+ {m.role === 'assistant' && m.tools && m.tools.length > 0 ? (<details className="chat-steps" open>
1429
+ <summary>
1430
+ {m.tools.length} tool call{m.tools.length === 1 ? '' : 's'}
1431
+ </summary>
1432
+ <ToolCards tools={m.tools}/>
1433
+ </details>) : null}
1434
+ {m.role === 'assistant' && m.steps && m.steps.length > 0 ? (<details className="chat-steps">
1435
+ <summary>{m.steps.length} step{m.steps.length === 1 ? '' : 's'}</summary>
1436
+ <div className="chat-step-line">
1437
+ {m.steps.map((l, j) => <div key={j}>{l.trim()}</div>)}
1438
+ </div>
1439
+ </details>) : null}
1440
+ </div>)))}
1441
+ {busy ? (<div className="chat-bubble chat-assistant">
1442
+ <div className="chat-bubble-role">πŸ€– Agent</div>
1443
+ {livePlan ? <PlanCard plan={livePlan}/> : null}
1444
+ {liveDiff ? <DiffCard diff={liveDiff}/> : null}
1445
+ {liveDraft ? <SkillDraftCard draft={liveDraft} onAccept={acceptDraft} onReject={rejectDraft} onEdit={editDraft}/> : null}
1446
+ {secretRequests.map((sr) => (<SecretRequestCard key={sr.skillName} request={sr} onSave={saveSecrets}/>))}
1447
+ {executionResults.map((er, idx) => (<ExecutionResultCard key={`${er.skillName}-${idx}`} result={er}/>))}
1448
+ <ToolCards tools={liveTools} live/>
1449
+ {streamingText ? (<div className="chat-bubble-text chat-streaming">
1450
+ <Markdown_1.default text={streamingText}/>
1451
+ </div>) : null}
1452
+ <div className="chat-working">
1453
+ {liveSteps.length > 0 ? (<span className="chat-working-lines">
1454
+ {liveSteps.map((l, i) => <div key={i} className="chat-step-line">{l.trim()}</div>)}
1455
+ </span>) : (<>πŸ’­ thinking… <span className="chat-dots"/></>)}
1456
+ </div>
1457
+ </div>) : null}
1458
+ </div>
1459
+
1460
+ {error ? <div className="admin-row-msg admin-row-msg-err">{error}</div> : null}
1461
+
1462
+ {retryAsk && !busy ? (<div className="chat-retry-row">
1463
+ <button className="admin-refresh-btn" type="button" onClick={() => void send(retryAsk)}>
1464
+ ↻ Retry
1465
+ </button>
1466
+ <span className="admin-hint">Re-send the last message β€” the turn failed.</span>
1467
+ </div>) : null}
1468
+
1469
+ {pendingResolve && !busy ? (<div className="chat-resolve-card">
1470
+ {pendingResolve.top?.ambiguous && pendingResolve.top.options?.length ? (<>
1471
+ <div className="chat-resolve-head">
1472
+ <strong>πŸ€” Which do you mean?</strong> β€” "{pendingResolve.ask}" maps to more than one action.
1473
+ </div>
1474
+ <div className="chat-resolve-options">
1475
+ {pendingResolve.top.options.map((o) => (<button key={o.command} className="chat-chip" type="button" onClick={() => void send(o.command)}>
1476
+ {o.summary} β€” <code>{o.command}</code>
1477
+ </button>))}
1478
+ </div>
1479
+ <div className="chat-resolve-foot">
1480
+ <button className="admin-mini-btn" type="button" onClick={() => { setPendingResolve(null); }}>βœ• Not that β€” ask the agent</button>
1481
+ </div>
1482
+ </>) : (<>
1483
+ <div className="chat-resolve-head">
1484
+ <strong>⚑ Run this command?</strong> β€” "{pendingResolve.ask}"
1485
+ </div>
1486
+ <div className="chat-resolve-cmd"><code>{pendingResolve.top?.command}</code></div>
1487
+ {pendingResolve.top?.confirmation ? (<div className="admin-hint">⚠ This changes running services/state.</div>) : null}
1488
+ <div className="chat-resolve-foot">
1489
+ <button className="admin-refresh-btn" type="button" onClick={() => void runResolvedCommand(pendingResolve.ask)}>
1490
+ β–Ά Run
1491
+ </button>
1492
+ <button className="admin-mini-btn" type="button" onClick={() => declineResolvedCommand(pendingResolve.ask)}>
1493
+ βœ• No β€” ask the agent
1494
+ </button>
1495
+ </div>
1496
+ </>)}
1497
+ </div>) : null}
1498
+
1499
+ {pendingQuestion ? (<div className="chat-resolve-card chat-question-card">
1500
+ <div className="chat-resolve-head">
1501
+ <strong>πŸ€” {pendingQuestion.question}</strong>
1502
+ </div>
1503
+ <div className="chat-resolve-options">
1504
+ {pendingQuestion.choices.map((c, i) => (<button key={`${c.label}-${i}`} type="button" className={`chat-chip${questionSel.has(i) ? ' chat-chip-selected' : ''}`} onClick={() => toggleQuestionChoice(i)}>
1505
+ {c.label}
1506
+ {c.description ? <span className="admin-hint"> β€” {c.description}</span> : null}
1507
+ </button>))}
1508
+ </div>
1509
+ <div className="chat-resolve-foot">
1510
+ <button className="admin-refresh-btn" type="button" onClick={submitQuestion}>
1511
+ {pendingQuestion.multiSelect ? 'Submit' : 'Choose'}
1512
+ </button>
1513
+ <button className="admin-mini-btn" type="button" onClick={skipQuestion}>
1514
+ Skip β€” best judgment
1515
+ </button>
1516
+ </div>
1517
+ </div>) : null}
1518
+
1519
+ {latestFollowups.length > 0 && !busy ? (<div className="chat-followups">
1520
+ <span className="admin-hint">Next steps:</span>
1521
+ {latestFollowups.map((f, i) => (<button key={i} className="chat-chip" type="button" onClick={() => void send(f.prompt)}>
1522
+ {f.label || f.prompt}
1523
+ </button>))}
1524
+ </div>) : null}
1525
+
1526
+ <form className={`chat-composer${dragOver ? ' chat-composer-drag' : ''}`} onSubmit={(e) => { e.preventDefault(); void send(input); }} onDragOver={(e) => { e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => {
1527
+ e.preventDefault();
1528
+ setDragOver(false);
1529
+ const files = Array.from(e.dataTransfer?.files ?? []);
1530
+ for (const f of files.slice(0, 10))
1531
+ void pickFile(f);
1532
+ }}>
1533
+ {pasteOffer ? (<div className="chat-paste-offer">
1534
+ <span className="admin-hint">
1535
+ πŸ“„ You pasted {pasteOffer.text.length.toLocaleString()} characters. Attach it as a file instead?
1536
+ </span>
1537
+ <button type="button" className="chat-chip" onClick={acceptPasteOffer}>Attach as text</button>
1538
+ <button type="button" className="chat-mini-action" onClick={() => { setPasteOffer(null); pastePosRef.current = null; }}>Keep inline</button>
1539
+ </div>) : null}
1540
+ {attachments.length > 0 ? (<div className="chat-attach-row">
1541
+ {attachments.map((a, i) => (<span key={`${a.name}-${i}`} className="chat-attach-chip" title={`${a.name} (${a.content.length.toLocaleString()} chars)`}>
1542
+ πŸ“Ž {a.name}
1543
+ <span className="admin-hint"> {a.content.length.toLocaleString()}c</span>
1544
+ <button type="button" className="chat-mini-action" onClick={() => removeAttachment(i)}>βœ•</button>
1545
+ </span>))}
1546
+ </div>) : null}
1547
+ <div className="chat-input-row">
1548
+ <input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={(e) => {
1549
+ const f = e.target.files?.[0];
1550
+ if (f)
1551
+ void pickFile(f);
1552
+ e.target.value = '';
1553
+ }}/>
1554
+ <button type="button" className="chat-attach-btn" title="Attach a file (up to 300 KB, text only)" disabled={busy || attachments.length >= 10} onClick={() => fileInputRef.current?.click()}>
1555
+ πŸ“Ž
1556
+ </button>
1557
+ <textarea className="chat-input-box" value={input} onChange={(e) => setInput(e.target.value)} onPaste={handlePaste} onKeyDown={(e) => {
1558
+ if (e.key === 'Enter' && !e.shiftKey) {
1559
+ e.preventDefault();
1560
+ void send(input);
1561
+ }
1562
+ else if (e.key === 'ArrowUp' && input === '' && lastSentRef.current) {
1563
+ // ↑ on an empty box recalls the last sent message (Phase 6).
1564
+ e.preventDefault();
1565
+ setInput(lastSentRef.current);
1566
+ }
1567
+ }} placeholder="Message the agent… (Enter to send Β· Shift+Enter for a new line Β· ↑ recalls last Β· πŸ“Ž attach a file or paste a large document)" disabled={busy} maxLength={8000} rows={1} autoFocus/>
1568
+ {busy ? (<button className="admin-mini-btn chat-cancel-btn" type="button" onClick={cancelTurn}>
1569
+ ⏹ Cancel
1570
+ </button>) : null}
1571
+ <button className="admin-refresh-btn" type="submit" disabled={busy || (!input.trim() && attachments.length === 0)}>
1572
+ {busy ? '⏳ Working…' : '➀ Send'}
1573
+ </button>
1574
+ </div>
1575
+ </form>
1576
+ <p className="admin-hint">
1577
+ Each message runs the full agent loop in the dashboard process (same engine as{' '}
1578
+ <code>buff chat "&lt;prompt&gt;"</code>) β€” the provider API keys must be configured in the dashboard
1579
+ process. Clarifications (<code>ask_user</code>) appear as a question card here β€” choose an answer or
1580
+ skip (best judgment).
1581
+ </p>
1582
+ </div>
1583
+ </div>
1584
+ </>)}
1585
+ </div>);
1586
+ }