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.
- package/dist/agents/orchestrator.d.ts.map +1 -1
- package/dist/agents/orchestrator.js +17 -3
- package/dist/agents/orchestrator.js.map +1 -1
- package/dist/app.js +26 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +108 -2
- package/dist/cli/chat.js.map +1 -1
- package/dist/config/auth.js +18 -0
- package/dist/config/jwt.js +24 -0
- package/dist/config/keys.js +14 -0
- package/dist/gateway/chat-store.d.ts +32 -5
- package/dist/gateway/chat-store.d.ts.map +1 -1
- package/dist/gateway/chat-store.js +52 -6
- package/dist/gateway/chat-store.js.map +1 -1
- package/dist/gateway/registry.d.ts.map +1 -1
- package/dist/gateway/registry.js +54 -10
- package/dist/gateway/registry.js.map +1 -1
- package/dist/inference/model-validator.d.ts.map +1 -1
- package/dist/inference/model-validator.js +11 -1
- package/dist/inference/model-validator.js.map +1 -1
- package/dist/inference/tool-call-utils.d.ts +31 -0
- package/dist/inference/tool-call-utils.d.ts.map +1 -1
- package/dist/inference/tool-call-utils.js +41 -0
- package/dist/inference/tool-call-utils.js.map +1 -1
- package/dist/learning/reasoning-trace.d.ts +5 -3
- package/dist/learning/reasoning-trace.d.ts.map +1 -1
- package/dist/learning/reasoning-trace.js +2 -2
- package/dist/learning/reasoning-trace.js.map +1 -1
- package/dist/middleware/auth.js +30 -0
- package/dist/nlu/conversation-gate.js +1 -1
- package/dist/nlu/conversation-gate.js.map +1 -1
- package/dist/nlu/entities.d.ts.map +1 -1
- package/dist/nlu/entities.js +12 -0
- package/dist/nlu/entities.js.map +1 -1
- package/dist/nlu/intent.d.ts.map +1 -1
- package/dist/nlu/intent.js +83 -15
- package/dist/nlu/intent.js.map +1 -1
- package/dist/passport.js +34 -0
- package/dist/routes/auth.js +42 -0
- package/dist/routes/user.js +31 -0
- package/dist/server.js +17 -0
- package/dist/tools/pipeline-tool.d.ts.map +1 -1
- package/dist/tools/pipeline-tool.js +14 -0
- package/dist/tools/pipeline-tool.js.map +1 -1
- package/dist/tools/release-sync.d.ts +52 -16
- package/dist/tools/release-sync.d.ts.map +1 -1
- package/dist/tools/release-sync.js +143 -79
- package/dist/tools/release-sync.js.map +1 -1
- package/dist/web-dashboard/src/App.js +85 -0
- package/dist/web-dashboard/src/admin-auth.test.js +186 -0
- package/dist/web-dashboard/src/ansi.js +23 -0
- package/dist/web-dashboard/src/ansi.test.js +31 -0
- package/dist/web-dashboard/src/api-admin-auth.test.js +172 -0
- package/dist/web-dashboard/src/api-admin.test.js +65 -0
- package/dist/web-dashboard/src/api-hub.test.js +117 -0
- package/dist/web-dashboard/src/api.js +1421 -0
- package/dist/web-dashboard/src/api.test.js +51 -0
- package/dist/web-dashboard/src/artifacts.js +128 -0
- package/dist/web-dashboard/src/artifacts.test.js +139 -0
- package/dist/web-dashboard/src/components/AdminPanel.js +567 -0
- package/dist/web-dashboard/src/components/AdminPanel.test.js +288 -0
- package/dist/web-dashboard/src/components/AgentHub.js +1580 -0
- package/dist/web-dashboard/src/components/AgentHub.test.js +343 -0
- package/dist/web-dashboard/src/components/BedrockOnboarding.js +320 -0
- package/dist/web-dashboard/src/components/BenchmarkCharts.js +228 -0
- package/dist/web-dashboard/src/components/ChatPage.js +1586 -0
- package/dist/web-dashboard/src/components/ChatPage.test.js +899 -0
- package/dist/web-dashboard/src/components/ContactsPage.js +141 -0
- package/dist/web-dashboard/src/components/CostDashboard.js +105 -0
- package/dist/web-dashboard/src/components/DAGView.js +477 -0
- package/dist/web-dashboard/src/components/DAGView.test.js +147 -0
- package/dist/web-dashboard/src/components/EnvVarEditor.js +138 -0
- package/dist/web-dashboard/src/components/EvalsPage.js +73 -0
- package/dist/web-dashboard/src/components/EvalsPage.test.js +120 -0
- package/dist/web-dashboard/src/components/ExecutionHistory.js +201 -0
- package/dist/web-dashboard/src/components/GatewayPage.js +40 -0
- package/dist/web-dashboard/src/components/GatewayPage.test.js +74 -0
- package/dist/web-dashboard/src/components/HealthPanel.js +68 -0
- package/dist/web-dashboard/src/components/HistoryBrowser.js +34 -0
- package/dist/web-dashboard/src/components/Layout.js +50 -0
- package/dist/web-dashboard/src/components/Markdown.js +152 -0
- package/dist/web-dashboard/src/components/Markdown.test.js +126 -0
- package/dist/web-dashboard/src/components/MarkdownZeroDep.js +237 -0
- package/dist/web-dashboard/src/components/MemoryPanel.js +81 -0
- package/dist/web-dashboard/src/components/ModelTimeline.js +298 -0
- package/dist/web-dashboard/src/components/ModelsPanel.js +1484 -0
- package/dist/web-dashboard/src/components/ModelsPanel.test.js +460 -0
- package/dist/web-dashboard/src/components/Overview.js +123 -0
- package/dist/web-dashboard/src/components/PhaseTimeline.js +359 -0
- package/dist/web-dashboard/src/components/PhaseTimeline.test.js +234 -0
- package/dist/web-dashboard/src/components/PlatformConfigSection.js +384 -0
- package/dist/web-dashboard/src/components/PlatformConfigSection.test.js +106 -0
- package/dist/web-dashboard/src/components/PlatformsPage.js +250 -0
- package/dist/web-dashboard/src/components/QuotaPanel.js +159 -0
- package/dist/web-dashboard/src/components/QuotaPanel.test.js +85 -0
- package/dist/web-dashboard/src/components/RequestsPanel.js +229 -0
- package/dist/web-dashboard/src/components/RequestsPanel.test.js +103 -0
- package/dist/web-dashboard/src/components/RoutingInsightsPanel.js +938 -0
- package/dist/web-dashboard/src/components/RoutingInsightsPanel.test.js +339 -0
- package/dist/web-dashboard/src/components/RoutingWalkthrough.js +408 -0
- package/dist/web-dashboard/src/components/RoutingWalkthrough.test.js +209 -0
- package/dist/web-dashboard/src/components/TaskConsole.js +119 -0
- package/dist/web-dashboard/src/components/TaskConsole.test.js +123 -0
- package/dist/web-dashboard/src/components/TasksPage.js +256 -0
- package/dist/web-dashboard/src/components/TasksPage.test.js +103 -0
- package/dist/web-dashboard/src/components/TracePanel.js +306 -0
- package/dist/web-dashboard/src/components/WhatsAppPanel.js +195 -0
- package/dist/web-dashboard/src/components/WhatsAppPanel.test.js +122 -0
- package/dist/web-dashboard/src/jsonOrNull.js +37 -0
- package/dist/web-dashboard/src/main.js +15 -0
- package/dist/web-dashboard/src/mask.js +11 -0
- package/dist/web-dashboard/vite.config.js +23 -0
- package/dist/web-dashboard/vitest.config.js +15 -0
- 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 "<prompt>"</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
|
+
}
|