@agentprojectcontext/apx 1.31.2 → 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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/constants.js +5 -0
  3. package/src/core/agent/run-agent.js +29 -1
  4. package/src/host/daemon/api/artifacts.js +117 -0
  5. package/src/host/daemon/api/code.js +12 -0
  6. package/src/host/daemon/plugins/desktop.js +34 -0
  7. package/src/host/daemon/plugins/telegram-ask.js +309 -0
  8. package/src/host/daemon/plugins/telegram.js +330 -2
  9. package/src/host/daemon/super-agent-tools/tools/ask-questions.js +96 -13
  10. package/src/interfaces/cli/commands/artifact.js +99 -0
  11. package/src/interfaces/cli/index.js +4 -0
  12. package/src/interfaces/cli/terminal-chat/renderer.js +22 -2
  13. package/src/interfaces/web/dist/assets/index-63P_ji1a.js +571 -0
  14. package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +1 -0
  15. package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +1 -0
  16. package/src/interfaces/web/dist/index.html +2 -2
  17. package/src/interfaces/web/package-lock.json +6 -6
  18. package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +72 -0
  19. package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +399 -0
  20. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  21. package/src/interfaces/web/src/components/chat/MessageList.tsx +2 -1
  22. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +230 -0
  23. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +12 -4
  24. package/src/interfaces/web/src/i18n/en.ts +20 -0
  25. package/src/interfaces/web/src/i18n/es.ts +20 -0
  26. package/src/interfaces/web/src/lib/api/artifacts.ts +47 -0
  27. package/src/interfaces/web/src/lib/api.ts +1 -0
  28. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +23 -2
  29. package/src/interfaces/web/src/screens/project/ChatTab.tsx +15 -0
  30. package/src/interfaces/web/dist/assets/index-BDUsA6L6.css +0 -1
  31. package/src/interfaces/web/dist/assets/index-BV615I9p.js +0 -548
  32. 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
- <ToolCall key={`${part.id}-${i}`} part={part} />
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-primary text-primary-fg"
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
+ }