@agentprojectcontext/apx 1.31.1 → 1.32.0
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/README.md +0 -1
- package/package.json +1 -1
- package/skills/apc-context/SKILL.md +0 -1
- package/src/core/agent/constants.js +5 -0
- package/src/core/agent/run-agent.js +29 -1
- package/src/core/confirmation/adapters/code.js +41 -0
- package/src/core/confirmation/adapters/telegram.js +134 -0
- package/src/core/confirmation/adapters/terminal.js +35 -0
- package/src/core/confirmation/adapters/web.js +53 -0
- package/src/core/confirmation/index.js +44 -0
- package/src/core/confirmation/pending-store.js +68 -0
- package/src/host/daemon/api/artifacts.js +117 -0
- package/src/host/daemon/api/code.js +14 -0
- package/src/host/daemon/api/confirm.js +30 -0
- package/src/host/daemon/api/super-agent.js +12 -4
- package/src/host/daemon/api.js +2 -0
- package/src/host/daemon/plugins/desktop.js +34 -0
- package/src/host/daemon/plugins/telegram-ask.js +309 -0
- package/src/host/daemon/plugins/telegram.js +358 -2
- package/src/host/daemon/super-agent-tools/helpers.js +27 -6
- package/src/host/daemon/super-agent-tools/index.js +1 -0
- package/src/host/daemon/super-agent-tools/tools/add-project.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/ask-questions.js +96 -13
- package/src/host/daemon/super-agent-tools/tools/call-mcp.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/call-runtime.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/edit-file.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/import-agent.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/run-shell.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/search-files.js +1 -4
- package/src/host/daemon/super-agent-tools/tools/send-telegram.js +1 -1
- package/src/host/daemon/super-agent-tools/tools/set-identity.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/set-permission-mode.js +2 -2
- package/src/host/daemon/super-agent-tools/tools/write-file.js +2 -2
- package/src/host/daemon/super-agent.js +5 -1
- package/src/interfaces/cli/commands/artifact.js +99 -0
- package/src/interfaces/cli/index.js +4 -0
- package/src/interfaces/cli/terminal-chat/renderer.js +22 -2
- package/src/interfaces/web/dist/assets/index-63P_ji1a.js +571 -0
- package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +72 -0
- package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +399 -0
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
- package/src/interfaces/web/src/components/chat/MessageList.tsx +2 -1
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +230 -0
- package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +12 -4
- package/src/interfaces/web/src/i18n/en.ts +20 -0
- package/src/interfaces/web/src/i18n/es.ts +20 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +47 -0
- package/src/interfaces/web/src/lib/api.ts +1 -0
- package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +23 -2
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +15 -0
- package/src/interfaces/web/dist/assets/index-BDUsA6L6.css +0 -1
- package/src/interfaces/web/dist/assets/index-BV615I9p.js +0 -548
- package/src/interfaces/web/dist/assets/index-BV615I9p.js.map +0 -1
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { X, CornerDownLeft } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { t } from "../../i18n";
|
|
5
|
+
|
|
6
|
+
// Normalized shape we get from the ask_questions tool (see ask-questions.js).
|
|
7
|
+
export interface AskOption {
|
|
8
|
+
label: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface AskQuestion {
|
|
12
|
+
question: string;
|
|
13
|
+
header?: string;
|
|
14
|
+
options?: AskOption[];
|
|
15
|
+
multiSelect?: boolean;
|
|
16
|
+
allowText?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
/** Stable key for this question batch (the assistant turn id). When it
|
|
21
|
+
* changes, the panel resets its internal selection state. */
|
|
22
|
+
turnKey: string;
|
|
23
|
+
questions: AskQuestion[];
|
|
24
|
+
/** Called with the final user-message string compiled from all answers. */
|
|
25
|
+
onSubmit: (compiled: string) => void;
|
|
26
|
+
/** Called when the user clicks the X to dismiss without answering. */
|
|
27
|
+
onDismiss?: () => void;
|
|
28
|
+
/** Hide the panel while the next turn is in flight. */
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One answer slot per question: which option indices are selected + free text.
|
|
33
|
+
interface AnswerState {
|
|
34
|
+
picked: Set<number>;
|
|
35
|
+
text: string;
|
|
36
|
+
skipped: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emptyAnswer(): AnswerState {
|
|
40
|
+
return { picked: new Set<number>(), text: "", skipped: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function compileAnswers(questions: AskQuestion[], answers: AnswerState[]): string {
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
questions.forEach((q, i) => {
|
|
46
|
+
const a = answers[i] || emptyAnswer();
|
|
47
|
+
if (a.skipped) {
|
|
48
|
+
lines.push(`- ${q.question}\n → (omitido)`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
if (q.options && q.options.length > 0) {
|
|
53
|
+
const labels = [...a.picked]
|
|
54
|
+
.sort((x, y) => x - y)
|
|
55
|
+
.map((idx) => q.options![idx]?.label)
|
|
56
|
+
.filter(Boolean) as string[];
|
|
57
|
+
if (labels.length > 0) parts.push(labels.join(", "));
|
|
58
|
+
}
|
|
59
|
+
const text = a.text.trim();
|
|
60
|
+
if (text) {
|
|
61
|
+
parts.push(q.options && q.options.length > 0 ? `(Otro: ${text})` : text);
|
|
62
|
+
}
|
|
63
|
+
const answerText = parts.length > 0 ? parts.join(" ") : "(sin respuesta)";
|
|
64
|
+
lines.push(`- ${q.question}\n → ${answerText}`);
|
|
65
|
+
});
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Inline panel rendered above the composer when the last assistant turn ended
|
|
70
|
+
// on an unanswered ask_questions call. Clones the Claude Code question UX:
|
|
71
|
+
// one question at a time, N/M progress, options + "Otro" free-text, skip / back
|
|
72
|
+
// / next controls. Submitting compiles every answer into a single user message
|
|
73
|
+
// so the agent sees them in the next turn.
|
|
74
|
+
export function InlineAskPanel({ turnKey, questions, onSubmit, onDismiss, disabled }: Props) {
|
|
75
|
+
const total = questions.length;
|
|
76
|
+
const [idx, setIdx] = useState(0);
|
|
77
|
+
const [answers, setAnswers] = useState<AnswerState[]>(() =>
|
|
78
|
+
questions.map(() => emptyAnswer()),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Reset when a new question batch arrives.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setIdx(0);
|
|
84
|
+
setAnswers(questions.map(() => emptyAnswer()));
|
|
85
|
+
}, [turnKey, questions]);
|
|
86
|
+
|
|
87
|
+
const current = questions[idx];
|
|
88
|
+
const answer = answers[idx] || emptyAnswer();
|
|
89
|
+
const hasOptions = !!current?.options && current.options.length > 0;
|
|
90
|
+
const multi = !!current?.multiSelect;
|
|
91
|
+
const allowText = current?.allowText !== false; // default true
|
|
92
|
+
|
|
93
|
+
const setAnswer = (patch: Partial<AnswerState>) => {
|
|
94
|
+
setAnswers((curr) => {
|
|
95
|
+
const next = [...curr];
|
|
96
|
+
const prev = next[idx] || emptyAnswer();
|
|
97
|
+
next[idx] = { ...prev, ...patch, skipped: false };
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const togglePick = (optionIdx: number) => {
|
|
103
|
+
setAnswers((curr) => {
|
|
104
|
+
const next = [...curr];
|
|
105
|
+
const prev = next[idx] || emptyAnswer();
|
|
106
|
+
const picked = new Set(prev.picked);
|
|
107
|
+
if (multi) {
|
|
108
|
+
if (picked.has(optionIdx)) picked.delete(optionIdx);
|
|
109
|
+
else picked.add(optionIdx);
|
|
110
|
+
} else {
|
|
111
|
+
picked.clear();
|
|
112
|
+
picked.add(optionIdx);
|
|
113
|
+
}
|
|
114
|
+
next[idx] = { ...prev, picked, skipped: false };
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const canAdvance = useMemo(() => {
|
|
120
|
+
// Always allow advancing — empty answer just records "(sin respuesta)".
|
|
121
|
+
// Skip is explicit via Omitir.
|
|
122
|
+
return true;
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const isLast = idx === total - 1;
|
|
126
|
+
|
|
127
|
+
const goPrev = () => setIdx((i) => Math.max(0, i - 1));
|
|
128
|
+
const goNext = () => {
|
|
129
|
+
if (isLast) {
|
|
130
|
+
onSubmit(compileAnswers(questions, answers));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
setIdx((i) => Math.min(total - 1, i + 1));
|
|
134
|
+
};
|
|
135
|
+
const skipCurrent = () => {
|
|
136
|
+
setAnswers((curr) => {
|
|
137
|
+
const next = [...curr];
|
|
138
|
+
next[idx] = { picked: new Set(), text: "", skipped: true };
|
|
139
|
+
return next;
|
|
140
|
+
});
|
|
141
|
+
if (isLast) {
|
|
142
|
+
// Have to compile from the about-to-be-updated state.
|
|
143
|
+
const nextAnswers = answers.map((a, i) =>
|
|
144
|
+
i === idx ? { picked: new Set<number>(), text: "", skipped: true } : a,
|
|
145
|
+
);
|
|
146
|
+
onSubmit(compileAnswers(questions, nextAnswers));
|
|
147
|
+
} else {
|
|
148
|
+
setIdx((i) => Math.min(total - 1, i + 1));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Cmd/Ctrl+Enter → Next/Submit. Number keys 1-9 → pick option (only when the
|
|
153
|
+
// text field doesn't have focus).
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const onKey = (e: KeyboardEvent) => {
|
|
156
|
+
if (disabled) return;
|
|
157
|
+
const targetTag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
|
158
|
+
const inField = targetTag === "input" || targetTag === "textarea";
|
|
159
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
goNext();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!inField && hasOptions && /^[1-9]$/.test(e.key)) {
|
|
165
|
+
const n = parseInt(e.key, 10) - 1;
|
|
166
|
+
if (n < (current?.options?.length || 0)) {
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
togglePick(n);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
window.addEventListener("keydown", onKey);
|
|
173
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!current || total === 0) return null;
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
className={cn(
|
|
181
|
+
"mx-3 mb-2 rounded-xl border border-border bg-card/95 shadow-xl backdrop-blur supports-[backdrop-filter]:bg-card/80",
|
|
182
|
+
disabled && "pointer-events-none opacity-60",
|
|
183
|
+
)}
|
|
184
|
+
data-testid="inline-ask-panel"
|
|
185
|
+
>
|
|
186
|
+
<header className="flex items-start gap-2 border-b border-border px-3 py-2">
|
|
187
|
+
<span className="mt-0.5 shrink-0 rounded-md bg-amber-500/15 px-1.5 py-0.5 text-[10px] font-mono font-medium text-amber-700 dark:text-amber-300">
|
|
188
|
+
{idx + 1}/{total}
|
|
189
|
+
</span>
|
|
190
|
+
{current.header && (
|
|
191
|
+
<span className="mt-0.5 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
192
|
+
{current.header}
|
|
193
|
+
</span>
|
|
194
|
+
)}
|
|
195
|
+
<p className="min-w-0 flex-1 text-sm font-semibold leading-snug">
|
|
196
|
+
{current.question}
|
|
197
|
+
</p>
|
|
198
|
+
{onDismiss && (
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={onDismiss}
|
|
202
|
+
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
203
|
+
aria-label={t("common.close")}
|
|
204
|
+
>
|
|
205
|
+
<X className="size-3.5" />
|
|
206
|
+
</button>
|
|
207
|
+
)}
|
|
208
|
+
</header>
|
|
209
|
+
|
|
210
|
+
<div className="space-y-1 px-2 py-2">
|
|
211
|
+
{hasOptions &&
|
|
212
|
+
current.options!.map((opt, i) => {
|
|
213
|
+
const checked = answer.picked.has(i);
|
|
214
|
+
return (
|
|
215
|
+
<button
|
|
216
|
+
key={`${i}:${opt.label}`}
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={() => togglePick(i)}
|
|
219
|
+
className={cn(
|
|
220
|
+
"flex w-full items-start gap-2 rounded-md border border-transparent px-2 py-1.5 text-left transition",
|
|
221
|
+
checked
|
|
222
|
+
? "border-emerald-500/40 bg-emerald-500/10"
|
|
223
|
+
: "hover:border-border hover:bg-accent/40",
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
226
|
+
<div className="min-w-0 flex-1">
|
|
227
|
+
<div className="text-xs font-medium">{opt.label}</div>
|
|
228
|
+
{opt.description && (
|
|
229
|
+
<div className="text-[11px] text-muted-foreground">
|
|
230
|
+
{opt.description}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
{multi ? (
|
|
235
|
+
<span
|
|
236
|
+
className={cn(
|
|
237
|
+
"mt-0.5 grid size-4 shrink-0 place-items-center rounded border",
|
|
238
|
+
checked
|
|
239
|
+
? "border-emerald-500 bg-emerald-500 text-white"
|
|
240
|
+
: "border-border bg-background",
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{checked && <span className="text-[10px] leading-none">✓</span>}
|
|
244
|
+
</span>
|
|
245
|
+
) : (
|
|
246
|
+
<span
|
|
247
|
+
className={cn(
|
|
248
|
+
"mt-0.5 grid size-4 shrink-0 place-items-center rounded border font-mono text-[10px]",
|
|
249
|
+
checked
|
|
250
|
+
? "border-emerald-500 bg-emerald-500 text-white"
|
|
251
|
+
: "border-border bg-muted text-muted-foreground",
|
|
252
|
+
)}
|
|
253
|
+
>
|
|
254
|
+
{i + 1}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
</button>
|
|
258
|
+
);
|
|
259
|
+
})}
|
|
260
|
+
|
|
261
|
+
{(allowText || !hasOptions) && (
|
|
262
|
+
<div className="rounded-md border border-transparent px-2 py-1.5 hover:border-border">
|
|
263
|
+
{hasOptions && (
|
|
264
|
+
<div className="mb-1 text-xs font-medium">
|
|
265
|
+
{t("ask_panel.other")}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
<input
|
|
269
|
+
type="text"
|
|
270
|
+
value={answer.text}
|
|
271
|
+
onChange={(e) => setAnswer({ text: e.target.value })}
|
|
272
|
+
placeholder={
|
|
273
|
+
hasOptions
|
|
274
|
+
? t("ask_panel.other_placeholder")
|
|
275
|
+
: t("ask_panel.text_placeholder")
|
|
276
|
+
}
|
|
277
|
+
className="w-full rounded border border-border bg-background px-2 py-1 text-xs outline-none focus:border-emerald-500"
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
|
|
283
|
+
<footer className="flex items-center justify-between gap-2 border-t border-border px-3 py-2">
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={goPrev}
|
|
287
|
+
disabled={idx === 0}
|
|
288
|
+
className="rounded px-2 py-1 text-[11px] text-muted-foreground hover:bg-accent disabled:opacity-30"
|
|
289
|
+
>
|
|
290
|
+
{t("ask_panel.back")}
|
|
291
|
+
</button>
|
|
292
|
+
<div className="flex items-center gap-1">
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
onClick={skipCurrent}
|
|
296
|
+
className="rounded px-2 py-1 text-[11px] text-muted-foreground hover:bg-accent"
|
|
297
|
+
>
|
|
298
|
+
{t("ask_panel.skip")}
|
|
299
|
+
</button>
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={goNext}
|
|
303
|
+
disabled={!canAdvance}
|
|
304
|
+
className="inline-flex items-center gap-1 rounded bg-emerald-500/15 px-2 py-1 text-[11px] font-medium text-emerald-700 hover:bg-emerald-500/25 dark:text-emerald-300"
|
|
305
|
+
>
|
|
306
|
+
{isLast ? t("ask_panel.submit") : t("ask_panel.next")}
|
|
307
|
+
<CornerDownLeft className="size-3 opacity-60" />
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
</footer>
|
|
311
|
+
</div>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Mirror of normalizeQuestion in src/host/daemon/super-agent-tools/tools/ask-questions.js.
|
|
316
|
+
// The server already normalizes, but the persisted `result` is stringified
|
|
317
|
+
// (see summarizeForTrace in run-agent.js), so we re-normalize locally rather
|
|
318
|
+
// than coupling to whichever shape happens to survive serialization.
|
|
319
|
+
function normalizeQuestionClient(q: unknown): AskQuestion | null {
|
|
320
|
+
if (typeof q === "string") {
|
|
321
|
+
return { question: q, options: [], multiSelect: false, allowText: true };
|
|
322
|
+
}
|
|
323
|
+
if (!q || typeof q !== "object") return null;
|
|
324
|
+
const obj = q as Record<string, unknown>;
|
|
325
|
+
const text = typeof obj.question === "string" ? obj.question : "";
|
|
326
|
+
if (!text) return null;
|
|
327
|
+
const rawOptions = Array.isArray(obj.options) ? obj.options : [];
|
|
328
|
+
const options: AskOption[] = rawOptions
|
|
329
|
+
.map((o: unknown) => {
|
|
330
|
+
if (typeof o === "string") return { label: o };
|
|
331
|
+
if (o && typeof o === "object" && typeof (o as Record<string, unknown>).label === "string") {
|
|
332
|
+
const oo = o as Record<string, unknown>;
|
|
333
|
+
return {
|
|
334
|
+
label: oo.label as string,
|
|
335
|
+
description: typeof oo.description === "string" ? (oo.description as string) : undefined,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
})
|
|
340
|
+
.filter((x): x is AskOption => x !== null);
|
|
341
|
+
return {
|
|
342
|
+
question: text,
|
|
343
|
+
header: typeof obj.header === "string" ? obj.header : undefined,
|
|
344
|
+
options,
|
|
345
|
+
multiSelect: obj.multiSelect === true,
|
|
346
|
+
allowText: obj.allowText === false ? false : true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Helper: pull questions from the last assistant turn if it ended on an
|
|
351
|
+
// unanswered ask_questions call. Returns null when there's nothing to ask.
|
|
352
|
+
// Tries args.questions first (raw model output) then result.questions
|
|
353
|
+
// (server-normalized, may be JSON-stringified).
|
|
354
|
+
export function pendingAskQuestions(msgs: Array<{ role: string; parts: Array<{ kind: string; tool?: string; args?: any; result?: any; status?: string }> }>): {
|
|
355
|
+
turnKey: string;
|
|
356
|
+
questions: AskQuestion[];
|
|
357
|
+
} | null {
|
|
358
|
+
if (!msgs.length) return null;
|
|
359
|
+
const last = msgs[msgs.length - 1];
|
|
360
|
+
if (last.role !== "assistant") return null;
|
|
361
|
+
// Find the most recent ask_questions tool part in this assistant turn.
|
|
362
|
+
let askPart: typeof last.parts[number] | null = null;
|
|
363
|
+
let askIdx = -1;
|
|
364
|
+
for (let i = last.parts.length - 1; i >= 0; i--) {
|
|
365
|
+
const p = last.parts[i];
|
|
366
|
+
if (p.kind === "tool" && p.tool === "ask_questions") {
|
|
367
|
+
askPart = p;
|
|
368
|
+
askIdx = i;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!askPart || askIdx < 0) return null;
|
|
373
|
+
|
|
374
|
+
// result may be a JSON-stringified blob (persisted shape via
|
|
375
|
+
// summarizeForTrace) or already an object (live stream events).
|
|
376
|
+
let resultObj: Record<string, unknown> | null = null;
|
|
377
|
+
if (typeof askPart.result === "string") {
|
|
378
|
+
try { resultObj = JSON.parse(askPart.result); } catch { resultObj = null; }
|
|
379
|
+
} else if (askPart.result && typeof askPart.result === "object") {
|
|
380
|
+
resultObj = askPart.result as Record<string, unknown>;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const sources: unknown[] = [];
|
|
384
|
+
if (Array.isArray(askPart.args?.questions)) sources.push(askPart.args!.questions);
|
|
385
|
+
if (resultObj && Array.isArray(resultObj.questions)) sources.push(resultObj.questions);
|
|
386
|
+
|
|
387
|
+
let qs: AskQuestion[] = [];
|
|
388
|
+
for (const src of sources) {
|
|
389
|
+
qs = (src as unknown[]).map(normalizeQuestionClient).filter((x): x is AskQuestion => !!x);
|
|
390
|
+
if (qs.length > 0) break;
|
|
391
|
+
}
|
|
392
|
+
if (!qs.length) return null;
|
|
393
|
+
|
|
394
|
+
// Stable key: assistant timestamp + tool part index keeps the panel from
|
|
395
|
+
// resetting mid-render while still resetting on a new turn.
|
|
396
|
+
const ts = (last as { ts?: string }).ts || "";
|
|
397
|
+
const turnKey = `${ts}#${askIdx}`;
|
|
398
|
+
return { turnKey, questions: qs };
|
|
399
|
+
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { Bot, Copy, User, Info } from "lucide-react";
|
|
2
2
|
import { cn } from "../../lib/cn";
|
|
3
3
|
import { ToolCall } from "./ToolCall";
|
|
4
|
+
import { AskQuestionsCard } from "./AskQuestionsCard";
|
|
4
5
|
import { textOf, type ChatMsg } from "../../hooks/useChat";
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
msg: ChatMsg;
|
|
9
|
+
/** True when this is the last message in the list. Used to detect if an
|
|
10
|
+
* ask_questions tool call is still waiting for the user vs already answered
|
|
11
|
+
* (a later user message would push this assistant turn off the bottom). */
|
|
12
|
+
isLast?: boolean;
|
|
8
13
|
onCopy?: (text: string) => void;
|
|
9
14
|
}
|
|
10
15
|
|
|
11
|
-
export function MessageBubble({ msg, onCopy }: Props) {
|
|
16
|
+
export function MessageBubble({ msg, isLast, onCopy }: Props) {
|
|
12
17
|
const mine = msg.role === "user";
|
|
13
18
|
const copyText = textOf(msg);
|
|
14
19
|
const hasTools = msg.parts.some((p) => p.kind === "tool");
|
|
@@ -35,14 +40,22 @@ export function MessageBubble({ msg, onCopy }: Props) {
|
|
|
35
40
|
{/* Ordered parts: interleaved assistant text + tool calls. */}
|
|
36
41
|
{msg.parts.map((part, i) =>
|
|
37
42
|
part.kind === "tool" ? (
|
|
38
|
-
|
|
43
|
+
part.tool === "ask_questions" && !mine ? (
|
|
44
|
+
<AskQuestionsCard
|
|
45
|
+
key={`${part.id}-${i}`}
|
|
46
|
+
part={part}
|
|
47
|
+
pending={!!isLast}
|
|
48
|
+
/>
|
|
49
|
+
) : (
|
|
50
|
+
<ToolCall key={`${part.id}-${i}`} part={part} />
|
|
51
|
+
)
|
|
39
52
|
) : part.text ? (
|
|
40
53
|
<div
|
|
41
54
|
key={i}
|
|
42
55
|
className={cn(
|
|
43
56
|
"whitespace-pre-wrap rounded-2xl px-3 py-2 text-sm leading-relaxed shadow-sm",
|
|
44
57
|
mine
|
|
45
|
-
? "rounded-br-sm bg-
|
|
58
|
+
? "rounded-br-sm border border-emerald-500/30 bg-emerald-500/10 text-foreground dark:bg-emerald-500/15"
|
|
46
59
|
: "w-full rounded-bl-sm border border-border bg-card text-foreground",
|
|
47
60
|
)}
|
|
48
61
|
>
|
|
@@ -24,10 +24,11 @@ export function MessageList({ msgs, onCopy }: Props) {
|
|
|
24
24
|
);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
const lastIdx = msgs.length - 1;
|
|
27
28
|
return (
|
|
28
29
|
<div className="space-y-4 px-3 py-4">
|
|
29
30
|
{msgs.map((m, i) => (
|
|
30
|
-
<MessageBubble key={i} msg={m} onCopy={onCopy} />
|
|
31
|
+
<MessageBubble key={i} msg={m} isLast={i === lastIdx} onCopy={onCopy} />
|
|
31
32
|
))}
|
|
32
33
|
<div ref={bottomRef} />
|
|
33
34
|
</div>
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import useSWR from "swr";
|
|
3
|
+
import { ChevronRight, Copy, RefreshCw, Trash2, FileCode2, Play } from "lucide-react";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { t } from "../../i18n";
|
|
6
|
+
import { Empty, Spinner } from "../ui";
|
|
7
|
+
import { Artifacts, type ArtifactEntry, type ArtifactRunResult } from "../../lib/api/artifacts";
|
|
8
|
+
import { useToast } from "../Toast";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
pid: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ArtifactRow({
|
|
15
|
+
pid,
|
|
16
|
+
entry,
|
|
17
|
+
onDeleted,
|
|
18
|
+
}: {
|
|
19
|
+
pid: string;
|
|
20
|
+
entry: ArtifactEntry;
|
|
21
|
+
onDeleted: () => void;
|
|
22
|
+
}) {
|
|
23
|
+
const [open, setOpen] = useState(false);
|
|
24
|
+
const [running, setRunning] = useState(false);
|
|
25
|
+
const [runResult, setRunResult] = useState<ArtifactRunResult | null>(null);
|
|
26
|
+
const toast = useToast();
|
|
27
|
+
const detail = useSWR(open ? ["artifact", pid, entry.name] : null, () =>
|
|
28
|
+
Artifacts.read(pid, entry.name),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Daemon-side detection: a file is runnable if it has the exec bit OR
|
|
32
|
+
// starts with a shebang. Locally we can only check the shebang from the
|
|
33
|
+
// fetched content; if it's missing we still show Run (the daemon will
|
|
34
|
+
// 400 cleanly and the toast surfaces the reason).
|
|
35
|
+
const looksRunnable = !detail.data?.content || detail.data.content.startsWith("#!");
|
|
36
|
+
|
|
37
|
+
const copy = async (text: string) => {
|
|
38
|
+
try {
|
|
39
|
+
await navigator.clipboard.writeText(text);
|
|
40
|
+
toast.info("Copiado.");
|
|
41
|
+
} catch {
|
|
42
|
+
/* ignore */
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const run = async () => {
|
|
47
|
+
setRunning(true);
|
|
48
|
+
setRunResult(null);
|
|
49
|
+
try {
|
|
50
|
+
const r = await Artifacts.run(pid, entry.name);
|
|
51
|
+
setRunResult(r);
|
|
52
|
+
if (r.ok) toast.info(`exit 0 — ${r.durationMs}ms`);
|
|
53
|
+
else toast.error(`exit ${r.exitCode ?? r.signal ?? "?"}${r.timedOut ? " (timeout)" : ""}`);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
toast.error((e as Error).message);
|
|
56
|
+
} finally {
|
|
57
|
+
setRunning(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const remove = async () => {
|
|
62
|
+
if (!window.confirm(t("code_module.artifacts_delete_confirm"))) return;
|
|
63
|
+
try {
|
|
64
|
+
await Artifacts.remove(pid, entry.name);
|
|
65
|
+
onDeleted();
|
|
66
|
+
} catch (e) {
|
|
67
|
+
toast.error((e as Error).message);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<li className="rounded-md border border-border">
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => setOpen((v) => !v)}
|
|
76
|
+
className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-accent/40"
|
|
77
|
+
>
|
|
78
|
+
<ChevronRight
|
|
79
|
+
className={cn(
|
|
80
|
+
"size-3 shrink-0 transition-transform",
|
|
81
|
+
open && "rotate-90",
|
|
82
|
+
)}
|
|
83
|
+
/>
|
|
84
|
+
<FileCode2 className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
|
85
|
+
<span className="min-w-0 flex-1 truncate font-mono">{entry.name}</span>
|
|
86
|
+
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">
|
|
87
|
+
{entry.size}b
|
|
88
|
+
</span>
|
|
89
|
+
</button>
|
|
90
|
+
{open && (
|
|
91
|
+
<div className="space-y-2 border-t border-border p-2">
|
|
92
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
93
|
+
<code className="min-w-0 flex-1 truncate rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
94
|
+
{entry.path}
|
|
95
|
+
</code>
|
|
96
|
+
{looksRunnable && (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => void run()}
|
|
100
|
+
disabled={running}
|
|
101
|
+
title={t("code_module.artifacts_run")}
|
|
102
|
+
className={cn(
|
|
103
|
+
"inline-flex items-center gap-1 rounded px-1.5 py-1 text-[10px] font-medium",
|
|
104
|
+
running
|
|
105
|
+
? "bg-muted text-muted-foreground"
|
|
106
|
+
: "bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/25 dark:text-emerald-300",
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{running ? <Spinner size={10} /> : <Play className="size-3" />}
|
|
110
|
+
{t("code_module.artifacts_run")}
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={() => void copy(entry.path)}
|
|
116
|
+
title={t("code_module.artifacts_copy_path")}
|
|
117
|
+
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
118
|
+
>
|
|
119
|
+
<Copy className="size-3" />
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={() => void remove()}
|
|
124
|
+
title={t("code_module.artifacts_delete")}
|
|
125
|
+
className="rounded p-1 text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950"
|
|
126
|
+
>
|
|
127
|
+
<Trash2 className="size-3" />
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
<div className="text-[10px] text-muted-foreground">
|
|
131
|
+
{t("code_module.artifacts_run_hint")}{" "}
|
|
132
|
+
<code className="rounded bg-muted px-1 font-mono">
|
|
133
|
+
apx artifact run {entry.name}
|
|
134
|
+
</code>
|
|
135
|
+
</div>
|
|
136
|
+
{runResult && (
|
|
137
|
+
<div className="space-y-1">
|
|
138
|
+
<div className="flex items-center gap-2 text-[10px]">
|
|
139
|
+
<span
|
|
140
|
+
className={cn(
|
|
141
|
+
"rounded px-1.5 py-0.5 font-mono",
|
|
142
|
+
runResult.ok
|
|
143
|
+
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
|
|
144
|
+
: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
|
|
145
|
+
)}
|
|
146
|
+
>
|
|
147
|
+
exit {runResult.exitCode ?? runResult.signal ?? "?"}
|
|
148
|
+
</span>
|
|
149
|
+
{runResult.timedOut && (
|
|
150
|
+
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 font-mono text-amber-700 dark:text-amber-300">
|
|
151
|
+
timeout
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
{runResult.truncated && (
|
|
155
|
+
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 font-mono text-amber-700 dark:text-amber-300">
|
|
156
|
+
truncated
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
<span className="font-mono text-muted-foreground">
|
|
160
|
+
{runResult.durationMs}ms
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
{runResult.stdout && (
|
|
164
|
+
<pre className="max-h-32 overflow-auto rounded bg-background/60 p-2 text-[10px] leading-tight">
|
|
165
|
+
{runResult.stdout}
|
|
166
|
+
</pre>
|
|
167
|
+
)}
|
|
168
|
+
{runResult.stderr && (
|
|
169
|
+
<pre className="max-h-32 overflow-auto rounded bg-rose-500/5 p-2 text-[10px] leading-tight text-rose-700 dark:text-rose-300">
|
|
170
|
+
{runResult.stderr}
|
|
171
|
+
</pre>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
{detail.isLoading ? (
|
|
176
|
+
<div className="flex justify-center py-2">
|
|
177
|
+
<Spinner size={12} />
|
|
178
|
+
</div>
|
|
179
|
+
) : detail.data?.content ? (
|
|
180
|
+
<pre className="max-h-64 overflow-auto rounded bg-muted/50 p-2 text-[10px] leading-tight">
|
|
181
|
+
{detail.data.content}
|
|
182
|
+
</pre>
|
|
183
|
+
) : null}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</li>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Artifacts tab: managed files stored under <project>/artifacts/. The agent
|
|
191
|
+
// puts reusable scripts here so the user can run them from a terminal.
|
|
192
|
+
export function CodeArtifactsTab({ pid }: Props) {
|
|
193
|
+
const list = useSWR(pid ? ["artifacts", pid] : null, () => Artifacts.list(pid));
|
|
194
|
+
const entries = list.data || [];
|
|
195
|
+
return (
|
|
196
|
+
<div className="flex h-full flex-col" data-testid="code-artifacts-tab">
|
|
197
|
+
<div className="flex shrink-0 items-center justify-between px-3 py-2">
|
|
198
|
+
<span className="text-[11px] text-muted-foreground">
|
|
199
|
+
{entries.length > 0
|
|
200
|
+
? t("code_module.artifacts_count", { n: entries.length })
|
|
201
|
+
: ""}
|
|
202
|
+
</span>
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
onClick={() => void list.mutate()}
|
|
206
|
+
title="↻"
|
|
207
|
+
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
208
|
+
>
|
|
209
|
+
{list.isLoading ? <Spinner size={12} /> : <RefreshCw className="size-3" />}
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
|
|
213
|
+
{entries.length === 0 ? (
|
|
214
|
+
<Empty>{t("code_module.artifacts_none")}</Empty>
|
|
215
|
+
) : (
|
|
216
|
+
<ul className="space-y-1.5">
|
|
217
|
+
{entries.map((a) => (
|
|
218
|
+
<ArtifactRow
|
|
219
|
+
key={a.name}
|
|
220
|
+
pid={pid}
|
|
221
|
+
entry={a}
|
|
222
|
+
onDeleted={() => void list.mutate()}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
</ul>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|