@agentprojectcontext/apx 1.32.2 → 1.33.1

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 (48) hide show
  1. package/package.json +1 -1
  2. package/skills/apc-context/SKILL.md +2 -5
  3. package/src/core/agent/prompts/action-discipline.md +12 -5
  4. package/src/core/agent/prompts/channels/telegram.md +9 -5
  5. package/src/core/apc/parser.js +1 -1
  6. package/src/core/apc/scaffold.js +3 -1
  7. package/src/core/apc/skill-sync.js +3 -1
  8. package/src/core/engines/gemini.js +28 -11
  9. package/src/core/engines/index.js +11 -1
  10. package/src/core/stores/code-sessions.js +4 -1
  11. package/src/host/daemon/api/artifacts.js +25 -0
  12. package/src/host/daemon/api/code.js +14 -1
  13. package/src/host/daemon/api/engines.js +31 -1
  14. package/src/host/daemon/api/exec.js +17 -2
  15. package/src/host/daemon/plugins/telegram/dispatch.js +573 -0
  16. package/src/host/daemon/plugins/telegram/helpers.js +130 -0
  17. package/src/host/daemon/plugins/telegram/index.js +19 -694
  18. package/src/interfaces/web/dist/assets/index-Aaiw8BZN.css +1 -0
  19. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js +602 -0
  20. package/src/interfaces/web/dist/assets/index-DPqtjDjh.js.map +1 -0
  21. package/src/interfaces/web/dist/index.html +2 -2
  22. package/src/interfaces/web/package-lock.json +3 -3
  23. package/src/interfaces/web/src/App.tsx +3 -1
  24. package/src/interfaces/web/src/components/ModelCombobox.tsx +42 -7
  25. package/src/interfaces/web/src/components/UiSelect.tsx +12 -2
  26. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +253 -111
  27. package/src/interfaces/web/src/components/code/CodeChangesTab.tsx +10 -8
  28. package/src/interfaces/web/src/components/code/CodeComposer.tsx +20 -17
  29. package/src/interfaces/web/src/components/code/CodeContextTab.tsx +43 -18
  30. package/src/interfaces/web/src/components/code/CodeFileTree.tsx +212 -0
  31. package/src/interfaces/web/src/components/code/CodeFileViewer.tsx +121 -0
  32. package/src/interfaces/web/src/components/code/CodeSessionList.tsx +30 -26
  33. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +23 -19
  34. package/src/interfaces/web/src/components/code/CodeTerminal.tsx +140 -0
  35. package/src/interfaces/web/src/components/common/TabLayout.tsx +3 -3
  36. package/src/interfaces/web/src/components/ui/chat-input.tsx +17 -6
  37. package/src/interfaces/web/src/hooks/useChat.ts +1 -0
  38. package/src/interfaces/web/src/hooks/useNavCollapseCtx.tsx +25 -1
  39. package/src/interfaces/web/src/i18n/es.ts +1 -1
  40. package/src/interfaces/web/src/lib/api/agents.ts +1 -1
  41. package/src/interfaces/web/src/lib/api/artifacts.ts +10 -0
  42. package/src/interfaces/web/src/lib/api/code.ts +4 -2
  43. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +423 -79
  44. package/src/interfaces/web/src/screens/project/ChatTab.tsx +7 -10
  45. package/src/core/util/text-similarity.js +0 -52
  46. package/src/interfaces/web/dist/assets/index-34U_Mp1M.css +0 -1
  47. package/src/interfaces/web/dist/assets/index-BkybwwRn.js +0 -570
  48. package/src/interfaces/web/dist/assets/index-BkybwwRn.js.map +0 -1
@@ -1,19 +1,47 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import useSWR from "swr";
3
- import { Code, Projects } from "../../lib/api";
4
- import { Badge, Empty, Loading } from "../../components/ui";
5
- import { useSetPageLabel } from "../../hooks/useNavCollapseCtx";
3
+ import { Bot, FolderTree, MessageSquare, PanelLeft, PanelRight, Terminal, X } from "lucide-react";
4
+ import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from "react-resizable-panels";
5
+ import { Code, Projects, Agents } from "../../lib/api";
6
+ import { Artifacts } from "../../lib/api/artifacts";
7
+ import { http } from "../../lib/http";
8
+ import { Empty, Loading } from "../../components/ui";
9
+ import { Tip } from "../../components/ui/tip";
10
+ import { UiSelect } from "../../components/UiSelect";
11
+ import { useSetPageLabel, useSetPageActions } from "../../hooks/useNavCollapseCtx";
6
12
  import { MessageList } from "../../components/chat/MessageList";
7
13
  import { CodeProjectPicker } from "../../components/code/CodeProjectPicker";
8
14
  import { CodeSessionList } from "../../components/code/CodeSessionList";
9
15
  import { CodeComposer } from "../../components/code/CodeComposer";
10
16
  import { CodeSidePanel } from "../../components/code/CodeSidePanel";
17
+ import { CodeFileTree } from "../../components/code/CodeFileTree";
18
+ import { CodeFileViewer } from "../../components/code/CodeFileViewer";
19
+ import { CodeTerminal } from "../../components/code/CodeTerminal";
11
20
  import { InlineAskPanel, pendingAskQuestions } from "../../components/chat/InlineAskPanel";
12
21
  import { useToast } from "../../components/Toast";
13
22
  import { t } from "../../i18n";
14
23
  import { applyStreamEvent, textOf, type ChatMsg } from "../../hooks/useChat";
15
24
  import type { CodeMode, CodeStreamEvent, CodeTurn } from "../../lib/api/code";
16
25
 
26
+ // Suppress unused import warning for textOf (kept for consumers)
27
+ void textOf;
28
+
29
+ const SUPER_AGENT_VALUE = "super-agent";
30
+
31
+ // Hit area is wider than the visible line so the handle is comfortable to
32
+ // grab — the inner ::before line is what the user sees.
33
+ function ResizeHandle() {
34
+ return (
35
+ <PanelResizeHandle className="relative z-10 w-px shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary/50 active:bg-primary/70" />
36
+ );
37
+ }
38
+
39
+ function ResizeHandleH() {
40
+ return (
41
+ <PanelResizeHandle className="relative z-10 h-px shrink-0 cursor-row-resize bg-border transition-colors hover:bg-primary/50 active:bg-primary/70" />
42
+ );
43
+ }
44
+
17
45
  // Code module — OpenCode-style coding sessions in the APX web admin. Each
18
46
  // project owns a list of persistent sessions; the daemon keeps the transcript
19
47
  // server-side (api/code.js), so the UI just streams turns and renders them with
@@ -26,11 +54,34 @@ export function CodeScreen() {
26
54
 
27
55
  const [pid, setPid] = useState<string>("");
28
56
  const [sid, setSid] = useState<string | null>(null);
57
+ const [agentSlug, setAgentSlug] = useState<string>(SUPER_AGENT_VALUE);
29
58
  const [msgs, setMsgs] = useState<ChatMsg[]>([]);
30
59
  const [draft, setDraft] = useState("");
31
60
  const [busy, setBusy] = useState(false);
61
+ const [leftOpen, setLeftOpen] = useState(true);
62
+ const [rightOpen, setRightOpen] = useState(true);
63
+ const [termOpen, setTermOpen] = useState(false);
64
+ const [termInitCmd, setTermInitCmd] = useState("");
65
+ const [worktreeOpen, setWorktreeOpen] = useState(false);
32
66
  const abortRef = useRef<AbortController | null>(null);
33
67
 
68
+ // Open file tabs. `artifactName` marks an artifact opened for editing;
69
+ // saves route through Artifacts.write instead of being read-only.
70
+ type OpenFile = {
71
+ path: string;
72
+ content: string;
73
+ loading?: boolean;
74
+ artifactName?: string;
75
+ };
76
+ const [openFiles, setOpenFiles] = useState<OpenFile[]>([]);
77
+ // "chat" is the permanent tab, otherwise a file path
78
+ const [activeTab, setActiveTab] = useState<string>("chat");
79
+
80
+ const runInTerminal = useCallback((cmd: string) => {
81
+ setTermOpen(true);
82
+ setTermInitCmd(cmd);
83
+ }, []);
84
+
34
85
  // Default to the first registered project once the list loads.
35
86
  useEffect(() => {
36
87
  if (!pid && projectList.length) setPid(String(projectList[0].id));
@@ -41,6 +92,9 @@ export function CodeScreen() {
41
92
  Code.sessions.list(pid),
42
93
  );
43
94
 
95
+ // Agents for the active project.
96
+ const agentsData = useSWR(pid ? ["agents", pid] : null, () => Agents.list(pid));
97
+
44
98
  // Full transcript of the active session.
45
99
  const session = useSWR(pid && sid ? ["code-session", pid, sid] : null, () =>
46
100
  Code.sessions.get(pid, sid!),
@@ -58,6 +112,11 @@ export function CodeScreen() {
58
112
  if (sid && list.length && !list.some((s) => s.id === sid)) setSid(list[0]?.id ?? null);
59
113
  }, [sessions.data, sid]);
60
114
 
115
+ // Sync agentSlug from the active session when it loads.
116
+ useEffect(() => {
117
+ if (session.data) setAgentSlug(session.data.agentSlug || SUPER_AGENT_VALUE);
118
+ }, [session.data]);
119
+
61
120
  // Hydrate the message list whenever the active session's transcript loads.
62
121
  // (Not while streaming — we own the array then.)
63
122
  useEffect(() => {
@@ -89,7 +148,10 @@ export function CodeScreen() {
89
148
  const onCreateSession = async () => {
90
149
  if (!pid || busy) return;
91
150
  try {
92
- const created = await Code.sessions.create(pid, { title: t("code_module.untitled") });
151
+ const created = await Code.sessions.create(pid, {
152
+ title: t("code_module.untitled"),
153
+ agentSlug: agentSlug !== SUPER_AGENT_VALUE ? agentSlug : null,
154
+ });
93
155
  await sessions.mutate();
94
156
  setSid(created.id);
95
157
  setMsgs([]);
@@ -126,6 +188,19 @@ export function CodeScreen() {
126
188
  };
127
189
 
128
190
  // Persist mode / model changes to the session (PATCH) + keep SWR in sync.
191
+ const onAgentChange = async (slug: string) => {
192
+ setAgentSlug(slug);
193
+ if (!sid) return;
194
+ try {
195
+ await Code.sessions.update(pid, sid, {
196
+ agentSlug: slug !== SUPER_AGENT_VALUE ? slug : null,
197
+ });
198
+ await Promise.all([session.mutate(), sessions.mutate()]);
199
+ } catch (e) {
200
+ toast.error((e as Error).message);
201
+ }
202
+ };
203
+
129
204
  const patchSession = useCallback(
130
205
  async (patch: { mode?: CodeMode; model?: string | null }) => {
131
206
  if (!sid) return;
@@ -207,12 +282,106 @@ export function CodeScreen() {
207
282
  }
208
283
  };
209
284
 
285
+ const openFile = useCallback(
286
+ (path: string) => {
287
+ setActiveTab(path);
288
+ setOpenFiles((prev) => {
289
+ if (prev.some((f) => f.path === path)) return prev; // already open
290
+ return [...prev, { path, content: "", loading: true }];
291
+ });
292
+ // Fetch content async
293
+ http
294
+ .post<{ ok: boolean; stdout: string; stderr: string }>("/run", {
295
+ cmd: `cat "${path}"`,
296
+ project: pid,
297
+ })
298
+ .then((r) => {
299
+ const content = r.stdout || r.stderr || "(vacío)";
300
+ setOpenFiles((prev) =>
301
+ prev.map((f) => (f.path === path ? { ...f, content, loading: false } : f)),
302
+ );
303
+ })
304
+ .catch((e: Error) => {
305
+ setOpenFiles((prev) =>
306
+ prev.map((f) =>
307
+ f.path === path ? { ...f, content: `Error: ${e.message}`, loading: false } : f,
308
+ ),
309
+ );
310
+ });
311
+ },
312
+ [pid],
313
+ );
314
+
315
+ const closeFile = useCallback((path: string) => {
316
+ setOpenFiles((prev) => prev.filter((f) => f.path !== path));
317
+ setActiveTab((prev) => (prev === path ? "chat" : prev));
318
+ }, []);
319
+
320
+ // Open an artifact as an EDITABLE tab. Reuses the file-tab UI but routes
321
+ // saves through Artifacts.write so the daemon persists the change.
322
+ const openArtifact = useCallback(
323
+ (name: string) => {
324
+ const tabPath = `artifacts/${name}`;
325
+ setActiveTab(tabPath);
326
+ setOpenFiles((prev) => {
327
+ if (prev.some((f) => f.path === tabPath)) return prev;
328
+ return [...prev, { path: tabPath, content: "", loading: true, artifactName: name }];
329
+ });
330
+ Artifacts.read(pid, name)
331
+ .then((r) => {
332
+ setOpenFiles((prev) =>
333
+ prev.map((f) =>
334
+ f.path === tabPath ? { ...f, content: r.content, loading: false } : f,
335
+ ),
336
+ );
337
+ })
338
+ .catch((e: Error) => {
339
+ setOpenFiles((prev) =>
340
+ prev.map((f) =>
341
+ f.path === tabPath ? { ...f, content: `Error: ${e.message}`, loading: false } : f,
342
+ ),
343
+ );
344
+ });
345
+ },
346
+ [pid],
347
+ );
348
+
349
+ const saveOpenFile = useCallback(
350
+ async (path: string, content: string) => {
351
+ const file = openFiles.find((f) => f.path === path);
352
+ if (!file?.artifactName) return;
353
+ try {
354
+ await Artifacts.write(pid, file.artifactName, content);
355
+ setOpenFiles((prev) =>
356
+ prev.map((f) => (f.path === path ? { ...f, content } : f)),
357
+ );
358
+ toast.info("Guardado.");
359
+ } catch (e) {
360
+ toast.error((e as Error).message);
361
+ }
362
+ },
363
+ [openFiles, pid, toast],
364
+ );
365
+
210
366
  const hasProjects = !projects.isLoading && projectList.length > 0;
367
+
368
+ const agentOptions = useMemo(() => {
369
+ const base = [{ value: SUPER_AGENT_VALUE, label: "super-agent", icon: Bot, description: "Agente principal con todas las herramientas" }];
370
+ const project = (agentsData.data || []).map((a) => ({
371
+ value: a.slug,
372
+ label: a.slug,
373
+ icon: Bot,
374
+ description: a.description || a.role || undefined,
375
+ }));
376
+ return [...base, ...project];
377
+ }, [agentsData.data]);
378
+
211
379
  const turns: CodeTurn[] = useMemo(() => msgs as unknown as CodeTurn[], [msgs]);
212
380
  const activeTitle = useMemo(
213
381
  () => sessions.data?.find((s) => s.id === sid)?.title || "",
214
382
  [sessions.data, sid],
215
383
  );
384
+ const activeProject = useMemo(() => projectList.find((p) => String(p.id) === pid), [projectList, pid]);
216
385
  useSetPageLabel(activeTitle);
217
386
 
218
387
  // Detect unanswered ask_questions in the last assistant turn. Local "dismissed"
@@ -225,8 +394,42 @@ export function CodeScreen() {
225
394
  void send(compiled);
226
395
  };
227
396
 
397
+ // Stable toggle callbacks
398
+ const toggleLeft = useCallback(() => setLeftOpen((v) => !v), []);
399
+ const toggleTree = useCallback(() => setWorktreeOpen((v) => !v), []);
400
+ const toggleTerm = useCallback(() => setTermOpen((v) => !v), []);
401
+ const toggleRight = useCallback(() => setRightOpen((v) => !v), []);
402
+
403
+ // Inject panel toggle icons into TopBar
404
+ const pageActions = useMemo(
405
+ () =>
406
+ sid ? (
407
+ <div className="flex items-center gap-0.5">
408
+ {[
409
+ { Icon: PanelLeft, open: leftOpen, toggle: toggleLeft, title: "Lista de sesiones" },
410
+ { Icon: FolderTree, open: worktreeOpen, toggle: toggleTree, title: "Árbol de archivos" },
411
+ { Icon: Terminal, open: termOpen, toggle: toggleTerm, title: "Terminal" },
412
+ { Icon: PanelRight, open: rightOpen, toggle: toggleRight, title: "Panel de contexto" },
413
+ ].map(({ Icon, open, toggle, title }) => (
414
+ <Tip key={title} content={title}>
415
+ <button
416
+ type="button"
417
+ onClick={toggle}
418
+ data-active={open}
419
+ className="rounded p-1 text-muted-fg transition-colors hover:bg-accent hover:text-accent-fg data-[active=true]:bg-accent data-[active=true]:text-accent-fg"
420
+ >
421
+ <Icon className="size-3.5" />
422
+ </button>
423
+ </Tip>
424
+ ))}
425
+ </div>
426
+ ) : null,
427
+ [sid, leftOpen, worktreeOpen, termOpen, rightOpen, toggleLeft, toggleTree, toggleTerm, toggleRight],
428
+ );
429
+ useSetPageActions(pageActions);
430
+
228
431
  return (
229
- <div className="flex h-full min-h-0" data-testid="screen-code">
432
+ <div className="flex h-full min-h-0 flex-col" data-testid="screen-code">
230
433
  {projects.isLoading ? (
231
434
  <Loading />
232
435
  ) : !hasProjects ? (
@@ -234,82 +437,223 @@ export function CodeScreen() {
234
437
  <Empty>{t("code_module.no_projects")}</Empty>
235
438
  </div>
236
439
  ) : (
237
- <>
238
- {/* Left: project picker + session list */}
239
- <aside className="flex w-52 shrink-0 flex-col border-r border-border">
240
- <div className="shrink-0 border-b border-border p-2">
241
- <CodeProjectPicker
242
- projects={projectList}
243
- value={pid}
244
- onChange={onPickProject}
245
- disabled={busy}
246
- />
247
- </div>
248
- <CodeSessionList
249
- sessions={sessions.data || []}
250
- activeId={sid}
251
- busy={busy}
252
- onSelect={onSelectSession}
253
- onCreate={onCreateSession}
254
- onRename={onRenameSession}
255
- onDelete={onDeleteSession}
256
- />
257
- <div className="shrink-0 border-t border-border px-3 py-2">
258
- <Badge tone="success">{t("code_module.badge")}</Badge>
259
- </div>
260
- </aside>
261
-
262
- {/* Center: transcript + composer */}
263
- <main className="flex min-w-0 flex-1 flex-col">
264
- <div className="min-h-0 flex-1 overflow-y-auto" data-testid="code-transcript">
265
- {!sid ? (
266
- <div className="grid h-full place-items-center p-6">
267
- <Empty>{t("code_module.pick_project")}</Empty>
268
- </div>
269
- ) : msgs.length ? (
270
- <MessageList msgs={msgs} onCopy={copyToClipboard} />
271
- ) : (
272
- <div className="grid h-full place-items-center p-6">
273
- <Empty>{t("code_module.empty_chat")}</Empty>
440
+ <PanelGroup
441
+ orientation="vertical"
442
+ id="code-layout-v"
443
+ className="min-h-0 flex-1"
444
+ >
445
+ {/* TOP: horizontal split across [left | tree | main | right] */}
446
+ <Panel id="top" defaultSize={termOpen ? "55%" : "100%"} minSize="20%">
447
+ <PanelGroup orientation="horizontal" id="code-layout" className="h-full">
448
+ {/* Left panel: session list + agent selector */}
449
+ {leftOpen && (
450
+ <>
451
+ <Panel id="left" defaultSize="14%" minSize="8%">
452
+ <aside className="flex h-full flex-col">
453
+ <div className="shrink-0 border-b border-border p-2">
454
+ <CodeProjectPicker
455
+ projects={projectList}
456
+ value={pid}
457
+ onChange={onPickProject}
458
+ disabled={busy}
459
+ />
460
+ </div>
461
+ <div className="min-h-0 flex-1 overflow-hidden">
462
+ <CodeSessionList
463
+ sessions={sessions.data || []}
464
+ activeId={sid}
465
+ busy={busy}
466
+ onSelect={onSelectSession}
467
+ onCreate={onCreateSession}
468
+ onRename={onRenameSession}
469
+ onDelete={onDeleteSession}
470
+ />
471
+ </div>
472
+ <div className="shrink-0 border-t border-border p-2">
473
+ <UiSelect
474
+ value={agentSlug}
475
+ onChange={onAgentChange}
476
+ options={agentOptions}
477
+ disabled={busy}
478
+ showIcon={true}
479
+ />
480
+ </div>
481
+ </aside>
482
+ </Panel>
483
+ <ResizeHandle />
484
+ </>
485
+ )}
486
+
487
+ {/* File tree panel */}
488
+ {worktreeOpen && (
489
+ <>
490
+ <Panel id="tree" defaultSize="13%" minSize="8%">
491
+ <div className="h-full">
492
+ <CodeFileTree pid={pid} projectPath={activeProject?.path} onOpenFile={openFile} />
493
+ </div>
494
+ </Panel>
495
+ <ResizeHandle />
496
+ </>
497
+ )}
498
+
499
+ {/* Main panel: tab bar (only with files) + transcript/file viewer + composer */}
500
+ <Panel id="main" defaultSize="50%" minSize="20%">
501
+ <div className="flex h-full flex-col">
502
+ {/* Tab bar — only when files are open */}
503
+ {openFiles.length > 0 && (
504
+ <div className="flex shrink-0 items-center gap-0 overflow-x-auto border-b border-border">
505
+ {/* Chat tab */}
506
+ <button
507
+ type="button"
508
+ onClick={() => setActiveTab("chat")}
509
+ data-active={activeTab === "chat"}
510
+ className="flex shrink-0 items-center gap-1.5 border-r border-border px-3 py-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/40 data-[active=true]:text-foreground"
511
+ >
512
+ <MessageSquare className="size-3 shrink-0" />
513
+ Chat
514
+ </button>
515
+ {/* File tabs */}
516
+ {openFiles.map((f) => {
517
+ const name = f.path.split("/").pop() ?? f.path;
518
+ const isActive = activeTab === f.path;
519
+ return (
520
+ <div
521
+ key={f.path}
522
+ data-active={isActive}
523
+ className="group flex shrink-0 items-center gap-1 border-r border-border px-2 py-2 text-[11px] text-muted-foreground transition-colors hover:bg-accent/40 data-[active=true]:text-foreground"
524
+ >
525
+ <Tip content={f.path}>
526
+ <button
527
+ type="button"
528
+ onClick={() => setActiveTab(f.path)}
529
+ className="min-w-0 max-w-[140px] truncate font-mono"
530
+ >
531
+ {name}
532
+ </button>
533
+ </Tip>
534
+ <Tip content="Cerrar">
535
+ <button
536
+ type="button"
537
+ onClick={() => closeFile(f.path)}
538
+ className="shrink-0 rounded p-0.5 opacity-60 hover:bg-accent hover:opacity-100"
539
+ >
540
+ <X className="size-2.5" />
541
+ </button>
542
+ </Tip>
543
+ </div>
544
+ );
545
+ })}
546
+ </div>
547
+ )}
548
+
549
+ {/* Tab content */}
550
+ {activeTab === "chat" ? (
551
+ <>
552
+ <div className="min-h-0 flex-1 overflow-y-auto" data-testid="code-transcript">
553
+ {!sid ? (
554
+ <div className="grid h-full place-items-center p-6">
555
+ <Empty>{t("code_module.pick_project")}</Empty>
556
+ </div>
557
+ ) : msgs.length ? (
558
+ <MessageList msgs={msgs} onCopy={copyToClipboard} />
559
+ ) : (
560
+ <div className="grid h-full place-items-center p-6">
561
+ <Empty>{t("code_module.empty_chat")}</Empty>
562
+ </div>
563
+ )}
564
+ </div>
565
+ {askVisible && pending && (
566
+ <InlineAskPanel
567
+ turnKey={pending.turnKey}
568
+ questions={pending.questions}
569
+ onSubmit={submitAnswers}
570
+ onDismiss={() => setDismissedKey(pending.turnKey)}
571
+ disabled={busy}
572
+ />
573
+ )}
574
+ </>
575
+ ) : (
576
+ <div className="min-h-0 flex-1 overflow-hidden">
577
+ {(() => {
578
+ const file = openFiles.find((f) => f.path === activeTab);
579
+ if (!file) return null;
580
+ return (
581
+ <CodeFileViewer
582
+ path={file.path}
583
+ content={file.content}
584
+ loading={file.loading}
585
+ onSave={
586
+ file.artifactName
587
+ ? (content) => saveOpenFile(file.path, content)
588
+ : undefined
589
+ }
590
+ />
591
+ );
592
+ })()}
593
+ </div>
594
+ )}
595
+
596
+ {/* Composer — always visible at the bottom of the main column */}
597
+ <div className="shrink-0 border-t border-border p-2" data-testid="code-input">
598
+ <CodeComposer
599
+ value={draft}
600
+ onValueChange={setDraft}
601
+ onSubmit={() => void send()}
602
+ onStop={stop}
603
+ busy={busy}
604
+ disabled={!sid}
605
+ mode={mode}
606
+ onModeChange={(m) => void patchSession({ mode: m })}
607
+ model={model}
608
+ onModelChange={(m) => void patchSession({ model: m || null })}
609
+ />
610
+ </div>
274
611
  </div>
612
+ </Panel>
613
+
614
+ {/* Right panel: context + changes + artifacts */}
615
+ {rightOpen && (
616
+ <>
617
+ <ResizeHandle />
618
+ <Panel id="right" defaultSize="22%" minSize="15%">
619
+ <aside className="flex h-full flex-col">
620
+ <CodeSidePanel
621
+ pid={pid}
622
+ turns={turns}
623
+ changes={changes.data}
624
+ changesLoading={changes.isLoading}
625
+ onRefreshChanges={() => void changes.mutate()}
626
+ session={
627
+ session.data
628
+ ? {
629
+ title: session.data.title,
630
+ mode: session.data.mode,
631
+ createdAt: session.data.createdAt,
632
+ updatedAt: session.data.updatedAt,
633
+ agentSlug: session.data.agentSlug ?? null,
634
+ }
635
+ : null
636
+ }
637
+ onRunInTerminal={runInTerminal}
638
+ onEditArtifact={openArtifact}
639
+ />
640
+ </aside>
641
+ </Panel>
642
+ </>
275
643
  )}
276
- </div>
277
- {askVisible && pending && (
278
- <InlineAskPanel
279
- turnKey={pending.turnKey}
280
- questions={pending.questions}
281
- onSubmit={submitAnswers}
282
- onDismiss={() => setDismissedKey(pending.turnKey)}
283
- disabled={busy}
284
- />
285
- )}
286
- <div className="border-t border-border bg-card/60 p-3" data-testid="code-input">
287
- <CodeComposer
288
- value={draft}
289
- onValueChange={setDraft}
290
- onSubmit={() => void send()}
291
- onStop={stop}
292
- busy={busy}
293
- disabled={!sid}
294
- mode={mode}
295
- onModeChange={(m) => void patchSession({ mode: m })}
296
- model={model}
297
- onModelChange={(m) => void patchSession({ model: m || null })}
298
- />
299
- </div>
300
- </main>
301
-
302
- {/* Right: context + changes */}
303
- <aside className="hidden w-72 shrink-0 flex-col border-l border-border lg:flex">
304
- <CodeSidePanel
305
- pid={pid}
306
- turns={turns}
307
- changes={changes.data}
308
- changesLoading={changes.isLoading}
309
- onRefreshChanges={() => void changes.mutate()}
310
- />
311
- </aside>
312
- </>
644
+ </PanelGroup>
645
+ </Panel>
646
+
647
+ {/* BOTTOM: terminal spanning the full width below all columns */}
648
+ {termOpen && pid && (
649
+ <>
650
+ <ResizeHandleH />
651
+ <Panel id="terminal" defaultSize="45%" minSize="10%" maxSize="80%">
652
+ <CodeTerminal pid={pid} initCmd={termInitCmd} onClose={toggleTerm} className="h-full" />
653
+ </Panel>
654
+ </>
655
+ )}
656
+ </PanelGroup>
313
657
  )}
314
658
  </div>
315
659
  );
@@ -61,11 +61,9 @@ export function ChatTab({ pid }: { pid: string }) {
61
61
  const resetConversation = () => clear();
62
62
 
63
63
  const send = async (text: string) => {
64
- // Roby (super-agent) is always available; a real project agent requires
65
- // that the project actually has one configured.
66
64
  if (!activeIsRoby && !activeAgent) return;
67
65
  await sendChat(text, {
68
- model: activeIsRoby ? model : undefined,
66
+ model: model || undefined,
69
67
  agentSlug: activeIsRoby ? undefined : activeAgent!.slug,
70
68
  });
71
69
  };
@@ -77,15 +75,12 @@ export function ChatTab({ pid }: { pid: string }) {
77
75
 
78
76
  if (agents.isLoading) return <Loading />;
79
77
 
80
- // Header subtitle differs: with the super-agent selected the chat goes
81
- // through the super-agent loop (it CAN call tools); a project agent is a
82
- // direct LLM call.
83
78
  const headerSubtitle = activeIsRoby
84
79
  ? t("project.chat.superagent_subtitle", { persona })
85
80
  : t("project.chat.subtitle");
86
81
 
87
82
  return (
88
- <div className="flex h-[calc(100vh-11rem)] flex-col overflow-hidden rounded-xl border border-border bg-card/40">
83
+ <div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card/40">
89
84
  <header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
90
85
  <div className="min-w-0">
91
86
  <h2 className="text-sm font-semibold">
@@ -118,7 +113,9 @@ export function ChatTab({ pid }: { pid: string }) {
118
113
  {msgs.length ? (
119
114
  <MessageList msgs={msgs} onCopy={copyToClipboard} />
120
115
  ) : (
121
- <Empty>{t("project.chat.empty")}</Empty>
116
+ <div className="flex h-full items-center justify-center p-8">
117
+ <p className="text-sm text-muted-fg">{t("project.chat.empty")}</p>
118
+ </div>
122
119
  )}
123
120
  </div>
124
121
  <ContextBar msgs={msgs} />
@@ -139,8 +136,8 @@ export function ChatTab({ pid }: { pid: string }) {
139
136
  onSend={send}
140
137
  onStop={stop}
141
138
  streaming={streaming}
142
- model={activeIsRoby ? model : undefined}
143
- onModelChange={activeIsRoby ? setModel : undefined}
139
+ model={model}
140
+ onModelChange={setModel}
144
141
  />
145
142
  <CreateAgentDialog
146
143
  open={creating}