@agentprojectcontext/apx 1.35.0 → 1.36.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.
@@ -18,8 +18,8 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-C0fm31dY.js"></script>
22
- <link rel="stylesheet" crossorigin href="/assets/index-UcAqlBO6.css">
21
+ <script type="module" crossorigin src="/assets/index-DJKA763h.js"></script>
22
+ <link rel="stylesheet" crossorigin href="/assets/index-Cm0KyPoZ.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
25
25
  <div id="root"></div>
@@ -179,7 +179,6 @@ function settingsLabel(key?: string) {
179
179
  function projectLabel(key?: string) {
180
180
  switch (key) {
181
181
  case "chat": return t("project.nav.chat");
182
- case "threads": return t("project.nav.threads");
183
182
  case "telegram": return t("project.nav.telegram");
184
183
  case "agents": return t("project.nav.agents");
185
184
  case "routines": return t("project.nav.routines");
@@ -0,0 +1,412 @@
1
+ import { useEffect, useMemo, useState, type ElementType } from "react";
2
+ import useSWR from "swr";
3
+ import clsx from "clsx";
4
+ import {
5
+ Bot,
6
+ ChevronDown,
7
+ ChevronRight,
8
+ Clock,
9
+ FolderOpen,
10
+ MessageSquare,
11
+ Mic,
12
+ Monitor,
13
+ Plus,
14
+ Send,
15
+ Sparkles,
16
+ Timer,
17
+ User,
18
+ } from "lucide-react";
19
+ import { Conversations } from "../../lib/api";
20
+ import { Input, Loading } from "../ui";
21
+ import { UiSelect } from "../UiSelect";
22
+ import { t } from "../../i18n";
23
+ import type { AgentEntry, ConversationListEntry } from "../../types/daemon";
24
+
25
+ // Channel taxonomy — same channels the daemon writes ("web", "voice",
26
+ // "desktop", "telegram", …) folded into 8 sidebar groups. Each group has an
27
+ // icon + a fixed display order so the sidebar is stable across reloads.
28
+ export type ChannelGroupKey =
29
+ | "live"
30
+ | "telegram"
31
+ | "voice"
32
+ | "desktop"
33
+ | "web"
34
+ | "a2a"
35
+ | "schedule"
36
+ | "other";
37
+
38
+ interface GroupMeta {
39
+ label: string;
40
+ icon: ElementType;
41
+ order: number;
42
+ }
43
+
44
+ const GROUP_META: Record<ChannelGroupKey, GroupMeta> = {
45
+ live: { label: "Live", icon: Sparkles, order: 0 },
46
+ telegram: { label: "Telegram", icon: Send, order: 1 },
47
+ voice: { label: "Voice", icon: Mic, order: 2 },
48
+ desktop: { label: "Desktop", icon: Monitor, order: 3 },
49
+ web: { label: "Web", icon: MessageSquare, order: 4 },
50
+ a2a: { label: "Agent ↔ Agent", icon: Bot, order: 5 },
51
+ schedule: { label: "Schedule", icon: Timer, order: 6 },
52
+ other: { label: "Other", icon: FolderOpen, order: 7 },
53
+ };
54
+
55
+ function channelGroup(channel?: string): ChannelGroupKey {
56
+ if (!channel) return "web";
57
+ const c = channel.toLowerCase();
58
+ if (c === "telegram") return "telegram";
59
+ if (c === "voice" || c === "overlay") return "voice";
60
+ if (c === "desktop") return "desktop";
61
+ if (c === "web" || c === "sidebar" || c === "web-sidebar") return "web";
62
+ if (c === "a2a" || c.startsWith("agent")) return "a2a";
63
+ if (c === "schedule" || c === "cron" || c === "routine") return "schedule";
64
+ return "other";
65
+ }
66
+
67
+ // Composite key identifying a sidebar selection: either a "live" agent session
68
+ // (no conversation file yet) or a persisted conversation tied to an agent.
69
+ export type ChatKey =
70
+ | { kind: "live"; agentSlug: string }
71
+ | { kind: "conv"; agentSlug: string; convId: string };
72
+
73
+ export function chatKeyToString(k: ChatKey): string {
74
+ return k.kind === "live" ? `live:${k.agentSlug}` : `conv:${k.agentSlug}:${k.convId}`;
75
+ }
76
+
77
+ interface Props {
78
+ pid: string;
79
+ agents: AgentEntry[];
80
+ /** Virtual super-agent slug used by the dropdown (kept in sync with ChatTab). */
81
+ superAgentSlug: string;
82
+ superAgentLabel: string;
83
+ selected: ChatKey;
84
+ onSelect: (key: ChatKey) => void;
85
+ onNewChat: () => void;
86
+ }
87
+
88
+ // Per-agent SWR fetcher. Lives in a child so adding/removing agents doesn't
89
+ // violate the rules of hooks in the parent.
90
+ function AgentConvFetcher({
91
+ pid,
92
+ slug,
93
+ onLoaded,
94
+ }: {
95
+ pid: string;
96
+ slug: string;
97
+ onLoaded: (slug: string, data: ConversationListEntry[] | undefined) => void;
98
+ }) {
99
+ const { data } = useSWR(
100
+ `/projects/${pid}/agents/${slug}/conversations`,
101
+ () => Conversations.list(pid, slug),
102
+ { revalidateOnFocus: false },
103
+ );
104
+ // Bubble fetched data up to the parent on every change.
105
+ useEffect(() => {
106
+ onLoaded(slug, data);
107
+ }, [slug, data]); // eslint-disable-line react-hooks/exhaustive-deps
108
+ return null;
109
+ }
110
+
111
+ export function ChatList({
112
+ pid,
113
+ agents,
114
+ superAgentSlug,
115
+ superAgentLabel,
116
+ selected,
117
+ onSelect,
118
+ onNewChat,
119
+ }: Props) {
120
+ const [query, setQuery] = useState("");
121
+ const [agentFilter, setAgentFilter] = useState("");
122
+ const [collapsed, setCollapsed] = useState<Partial<Record<ChannelGroupKey, boolean>>>({});
123
+ const [byAgent, setByAgent] = useState<Record<string, ConversationListEntry[]>>({});
124
+
125
+ const handleLoaded = (slug: string, data: ConversationListEntry[] | undefined) => {
126
+ if (!data) return;
127
+ setByAgent((prev) => {
128
+ const cur = prev[slug];
129
+ if (cur && cur.length === data.length && cur === data) return prev;
130
+ return { ...prev, [slug]: data };
131
+ });
132
+ };
133
+
134
+ const allConvs = useMemo<ConversationListEntry[]>(() => {
135
+ const out: ConversationListEntry[] = [];
136
+ for (const a of agents) {
137
+ for (const c of byAgent[a.slug] || []) {
138
+ out.push({ ...c, agent_slug: c.agent_slug || a.slug });
139
+ }
140
+ }
141
+ return out;
142
+ }, [agents, byAgent]);
143
+
144
+ const filteredConvs = useMemo(() => {
145
+ const q = query.trim().toLowerCase();
146
+ return allConvs.filter((c) => {
147
+ if (agentFilter && c.agent_slug !== agentFilter) return false;
148
+ if (q) {
149
+ const hay = `${c.title || ""} ${c.id} ${c.agent_slug}`.toLowerCase();
150
+ if (!hay.includes(q)) return false;
151
+ }
152
+ return true;
153
+ });
154
+ }, [allConvs, query, agentFilter]);
155
+
156
+ // Group: live entries (one per applicable agent) + stored conversations by channel.
157
+ const groups = useMemo(() => {
158
+ const byKey = new Map<ChannelGroupKey, ConversationListEntry[]>();
159
+ for (const c of filteredConvs) {
160
+ const key = channelGroup(c.channel);
161
+ const bucket = byKey.get(key);
162
+ if (bucket) bucket.push(c);
163
+ else byKey.set(key, [c]);
164
+ }
165
+ return Array.from(byKey.entries())
166
+ .map(([key, items]) => ({
167
+ key,
168
+ items: items.sort(
169
+ (a, b) => new Date(b.started_at || 0).getTime() - new Date(a.started_at || 0).getTime(),
170
+ ),
171
+ }))
172
+ .sort((a, b) => GROUP_META[a.key].order - GROUP_META[b.key].order);
173
+ }, [filteredConvs]);
174
+
175
+ // "Live" agents shown at the top: super-agent + project agents matching the
176
+ // current agent filter (so "filter by foo" hides the others everywhere).
177
+ const liveAgents = useMemo(() => {
178
+ if (agentFilter === superAgentSlug) {
179
+ return [{ slug: superAgentSlug, label: superAgentLabel }];
180
+ }
181
+ const out: { slug: string; label: string }[] = [];
182
+ if (!agentFilter) out.push({ slug: superAgentSlug, label: superAgentLabel });
183
+ for (const a of agents) {
184
+ if (agentFilter && a.slug !== agentFilter) continue;
185
+ out.push({ slug: a.slug, label: a.slug });
186
+ }
187
+ return out;
188
+ }, [agents, agentFilter, superAgentSlug, superAgentLabel]);
189
+
190
+ const agentOptions = useMemo(
191
+ () => [
192
+ { value: "", label: t("project.chat.list.all_agents") },
193
+ { value: superAgentSlug, label: superAgentLabel },
194
+ ...agents.map((a) => ({ value: a.slug, label: a.slug })),
195
+ ],
196
+ [agents, superAgentSlug, superAgentLabel],
197
+ );
198
+
199
+ const totalCount = allConvs.length + liveAgents.length;
200
+ const anyLoaded = Object.keys(byAgent).length > 0 || agents.length === 0;
201
+
202
+ return (
203
+ <aside className="flex h-full w-72 shrink-0 flex-col border-r border-border bg-card/30">
204
+ {/* Hidden per-agent fetchers — one SWR per agent, but with a stable
205
+ component per slug so rules-of-hooks are respected. */}
206
+ {agents.map((a) => (
207
+ <AgentConvFetcher key={a.slug} pid={pid} slug={a.slug} onLoaded={handleLoaded} />
208
+ ))}
209
+
210
+ <header className="flex h-[57px] shrink-0 items-center justify-between gap-2 border-b border-border px-3">
211
+ <div className="min-w-0">
212
+ <p className="truncate text-sm font-semibold">{t("project.chat.list.title")}</p>
213
+ <p className="text-[10px] text-muted-fg">
214
+ {t("project.chat.list.count", { n: totalCount })}
215
+ </p>
216
+ </div>
217
+ <button
218
+ type="button"
219
+ onClick={onNewChat}
220
+ className="inline-flex items-center gap-1 rounded-md border border-border bg-accent/60 px-2 py-1 text-[11px] font-medium hover:bg-accent"
221
+ >
222
+ <Plus className="size-3" /> {t("project.chat.list.new")}
223
+ </button>
224
+ </header>
225
+
226
+ <div className="space-y-2 border-b border-border p-2">
227
+ <Input
228
+ value={query}
229
+ onChange={(e) => setQuery(e.target.value)}
230
+ placeholder={t("project.chat.list.search")}
231
+ />
232
+ <UiSelect value={agentFilter} onChange={setAgentFilter} options={agentOptions} />
233
+ </div>
234
+
235
+ <div className="flex-1 space-y-3 overflow-y-auto p-2">
236
+ {!anyLoaded && (
237
+ <div className="px-2 py-1">
238
+ <Loading />
239
+ </div>
240
+ )}
241
+
242
+ {/* Live group — synthetic, always first. */}
243
+ {liveAgents.length > 0 && (
244
+ <ChannelGroup
245
+ keyName="live"
246
+ count={liveAgents.length}
247
+ collapsed={!!collapsed.live}
248
+ onToggle={() => setCollapsed((p) => ({ ...p, live: !p.live }))}
249
+ >
250
+ {liveAgents.map((a) => {
251
+ const isRoby = a.slug === superAgentSlug;
252
+ const active =
253
+ selected.kind === "live" && selected.agentSlug === a.slug;
254
+ return (
255
+ <ChatListItem
256
+ key={`live-${a.slug}`}
257
+ title={isRoby ? a.label : t("project.chat.list.live_with", { slug: a.slug })}
258
+ subtitle={t("project.chat.list.live_subtitle")}
259
+ badge={isRoby ? "super" : a.slug}
260
+ selected={active}
261
+ onClick={() => onSelect({ kind: "live", agentSlug: a.slug })}
262
+ />
263
+ );
264
+ })}
265
+ </ChannelGroup>
266
+ )}
267
+
268
+ {/* Stored conversations, grouped by channel. */}
269
+ {groups.map((g) => (
270
+ <ChannelGroup
271
+ key={g.key}
272
+ keyName={g.key}
273
+ count={g.items.length}
274
+ collapsed={!!collapsed[g.key]}
275
+ onToggle={() => setCollapsed((p) => ({ ...p, [g.key]: !p[g.key] }))}
276
+ >
277
+ {g.items.map((c) => {
278
+ const active =
279
+ selected.kind === "conv" &&
280
+ selected.agentSlug === c.agent_slug &&
281
+ selected.convId === c.id;
282
+ return (
283
+ <ChatListItem
284
+ key={`${c.agent_slug}-${c.id}`}
285
+ title={c.title || c.id}
286
+ subtitle={[c.agent_slug, `${c.messages ?? 0} msg`]
287
+ .filter(Boolean)
288
+ .join(" · ")}
289
+ badge={c.agent_slug}
290
+ timeAgo={c.started_at}
291
+ selected={active}
292
+ onClick={() =>
293
+ onSelect({ kind: "conv", agentSlug: c.agent_slug, convId: c.id })
294
+ }
295
+ />
296
+ );
297
+ })}
298
+ </ChannelGroup>
299
+ ))}
300
+
301
+ {anyLoaded && allConvs.length === 0 && (
302
+ <p className="px-3 py-6 text-center text-xs text-muted-fg">
303
+ {t("project.chat.list.empty")}
304
+ </p>
305
+ )}
306
+ </div>
307
+ </aside>
308
+ );
309
+ }
310
+
311
+ function ChannelGroup({
312
+ keyName,
313
+ count,
314
+ collapsed,
315
+ onToggle,
316
+ children,
317
+ }: {
318
+ keyName: ChannelGroupKey;
319
+ count: number;
320
+ collapsed: boolean;
321
+ onToggle: () => void;
322
+ children: React.ReactNode;
323
+ }) {
324
+ const meta = GROUP_META[keyName];
325
+ const Icon = meta.icon;
326
+ return (
327
+ <section className="space-y-1">
328
+ <button
329
+ type="button"
330
+ onClick={onToggle}
331
+ className="flex w-full items-center justify-between rounded-md px-2 py-1 text-muted-fg hover:bg-accent/30"
332
+ >
333
+ <span className="inline-flex items-center gap-1.5">
334
+ {collapsed ? (
335
+ <ChevronRight className="size-3" />
336
+ ) : (
337
+ <ChevronDown className="size-3" />
338
+ )}
339
+ <Icon className="size-3" />
340
+ <span className="text-[10px] font-semibold uppercase tracking-wider">
341
+ {meta.label}
342
+ </span>
343
+ </span>
344
+ <span className="text-[10px]">{count}</span>
345
+ </button>
346
+ {!collapsed && <div className="space-y-0.5">{children}</div>}
347
+ </section>
348
+ );
349
+ }
350
+
351
+ function ChatListItem({
352
+ title,
353
+ subtitle,
354
+ badge,
355
+ timeAgo,
356
+ selected,
357
+ onClick,
358
+ }: {
359
+ title: string;
360
+ subtitle?: string;
361
+ badge?: string;
362
+ timeAgo?: string;
363
+ selected?: boolean;
364
+ onClick: () => void;
365
+ }) {
366
+ return (
367
+ <button
368
+ type="button"
369
+ onClick={onClick}
370
+ className={clsx(
371
+ "w-full rounded-md border px-2.5 py-2 text-left transition-colors",
372
+ selected
373
+ ? "border-primary/50 bg-primary/10"
374
+ : "border-transparent hover:border-border hover:bg-accent/40",
375
+ )}
376
+ >
377
+ <div className="flex items-start justify-between gap-2">
378
+ <p className={clsx("truncate text-sm", selected ? "font-semibold" : "font-medium")}>
379
+ {title}
380
+ </p>
381
+ {timeAgo && (
382
+ <span className="inline-flex shrink-0 items-center gap-0.5 text-[10px] text-muted-fg">
383
+ <Clock className="size-2.5" />
384
+ {formatTimeAgo(timeAgo)}
385
+ </span>
386
+ )}
387
+ </div>
388
+ <div className="mt-0.5 flex items-center justify-between gap-2 text-[10px] text-muted-fg">
389
+ <span className="truncate">{subtitle}</span>
390
+ {badge && (
391
+ <span className="inline-flex shrink-0 items-center gap-1 rounded bg-accent/50 px-1.5 py-0.5">
392
+ <User className="size-2.5" />
393
+ {badge}
394
+ </span>
395
+ )}
396
+ </div>
397
+ </button>
398
+ );
399
+ }
400
+
401
+ function formatTimeAgo(iso?: string): string {
402
+ if (!iso) return "";
403
+ const ts = new Date(iso).getTime();
404
+ if (!Number.isFinite(ts)) return "";
405
+ const diff = Date.now() - ts;
406
+ const m = Math.floor(diff / 60000);
407
+ if (m < 1) return "now";
408
+ if (m < 60) return `${m}m`;
409
+ const h = Math.floor(m / 60);
410
+ if (h < 24) return `${h}h`;
411
+ return `${Math.floor(h / 24)}d`;
412
+ }
@@ -30,7 +30,7 @@ export function AppearancePanel() {
30
30
  };
31
31
 
32
32
  return (
33
- <div className="space-y-6">
33
+ <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
34
34
  <Section title={t("settings.appearance")}>
35
35
  <div className="flex items-center gap-2">
36
36
  <Button variant={theme === "light" ? "primary" : "secondary"} onClick={() => set("light")}>{t("settings.light_mode")}</Button>
@@ -101,7 +101,7 @@ export function MemoryPanel() {
101
101
  };
102
102
 
103
103
  return (
104
- <div className="space-y-6">
104
+ <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
105
105
  <Section
106
106
  title={t("memory_panel.embeddings_title")}
107
107
  description="Modelo que vectoriza el historial de todos los canales para la memoria relevante. Igual que TTS/STT: elegí proveedor y modelo. 'Automático' prueba local primero y cae al offline si no hay nada."
@@ -88,7 +88,7 @@ export function SkillsInspectorPanel() {
88
88
  };
89
89
 
90
90
  return (
91
- <div className="space-y-6">
91
+ <div className="grid gap-6 xl:grid-cols-2 xl:items-start">
92
92
  <Section
93
93
  title="Skill Inspector (RAG por turno)"
94
94
  description="Función experimental. Cuando está activa, el agente NO recibe la lista completa de skills en su prompt; en cada mensaje un RAG local decide qué skill(s) cargar — el cuerpo completo si hay match fuerte, una sugerencia si hay match medio, nada si no aplica. Se reevalúa cada turno: una skill que dejó de ser relevante desaparece del contexto."
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
- import { SuperAgent, Agents } from "../lib/api";
2
+ import { SuperAgent, Agents, Conversations } from "../lib/api";
3
3
  import type { ChatStreamEvent, ChatUsage, ConversationMessage } from "../types/daemon";
4
4
 
5
5
  export type ToolStatus = "running" | "done" | "error" | "deduped";
@@ -53,7 +53,14 @@ export interface UseChatResult {
53
53
  send: (text: string, opts?: SendOptions) => Promise<void>;
54
54
  stop: () => void;
55
55
  clear: () => void;
56
+ /** Load a persisted conversation as history and bind subsequent sends to it.
57
+ * Only supported for project agents (super-agent conversations aren't
58
+ * persisted per-file). Pass `null` to drop the binding without clearing. */
59
+ load: (agentSlug: string, conversationId: string) => Promise<void>;
56
60
  streaming: boolean;
61
+ /** Conversation id we're bound to, if any. Lets callers reflect "live vs
62
+ * loaded" state in the UI. */
63
+ conversationId: string | undefined;
57
64
  }
58
65
 
59
66
  /** Concatenate the text parts of a message (for clipboard). */
@@ -235,6 +242,7 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
235
242
  export function useChat(pid: string, onError?: (msg: string) => void): UseChatResult {
236
243
  const [msgs, setMsgs] = useState<ChatMsg[]>([]);
237
244
  const [streaming, setStreaming] = useState(false);
245
+ const [conversationId, setConversationId] = useState<string | undefined>(undefined);
238
246
  const abortRef = useRef<AbortController | null>(null);
239
247
  const convoRef = useRef<string | undefined>(undefined);
240
248
 
@@ -283,8 +291,10 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
283
291
  prompt: trimmed,
284
292
  conversation_id: convoRef.current,
285
293
  model: opts.model || undefined,
294
+ channel: "web",
286
295
  });
287
296
  convoRef.current = out.conversation_id;
297
+ setConversationId(out.conversation_id);
288
298
  patchLast((m) => ({
289
299
  ...m,
290
300
  pending: false,
@@ -334,8 +344,31 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
334
344
  const clear = useCallback(() => {
335
345
  if (streaming) return;
336
346
  convoRef.current = undefined;
347
+ setConversationId(undefined);
337
348
  setMsgs([]);
338
349
  }, [streaming]);
339
350
 
340
- return { msgs, send, stop, clear, streaming };
351
+ const load = useCallback(
352
+ async (agentSlug: string, conversationId: string) => {
353
+ if (streaming) return;
354
+ try {
355
+ const detail = await Conversations.get(pid, agentSlug, conversationId);
356
+ const loaded: ChatMsg[] = detail.messages
357
+ .filter((m) => m.role === "user" || m.role === "assistant")
358
+ .map((m) => ({
359
+ role: m.role as "user" | "assistant",
360
+ parts: [{ kind: "text", text: m.content }],
361
+ ts: m.ts || new Date().toISOString(),
362
+ }));
363
+ convoRef.current = conversationId;
364
+ setConversationId(conversationId);
365
+ setMsgs(loaded);
366
+ } catch (e) {
367
+ onError?.((e as Error)?.message || "could not load conversation");
368
+ }
369
+ },
370
+ [pid, streaming, onError],
371
+ );
372
+
373
+ return { msgs, send, stop, clear, load, streaming, conversationId };
341
374
  }
@@ -275,7 +275,6 @@ export const en = {
275
275
  tasks: "Tasks",
276
276
  mcps: "MCPs",
277
277
  vars: "Variables",
278
- threads: "Chats",
279
278
  logs: "Logs",
280
279
  memories: "Memories",
281
280
  },
@@ -301,6 +300,7 @@ export const en = {
301
300
  subtitle: "Direct conversations with project agents. The super-agent does not intervene.",
302
301
  superagent_title: "Chat with {persona}",
303
302
  superagent_subtitle: "Chat with {persona} — the APX super-agent. Can use tools (projects, tasks, mcps, agents).",
303
+ loaded_subtitle: "Loaded conversation with {slug}. Sending will append to this thread.",
304
304
  empty: "Send a message to start the conversation.",
305
305
  placeholder: "Type something and press enter to send (shift+enter = new line)",
306
306
  send: "Send",
@@ -316,6 +316,16 @@ export const en = {
316
316
  model_label: "model",
317
317
  model_hint: "e.g. openai:gpt-5, groq:llama-3.3-70b-versatile",
318
318
  master_label: "Master agent",
319
+ list: {
320
+ title: "Chats",
321
+ new: "New",
322
+ search: "Search chats…",
323
+ all_agents: "All agents",
324
+ empty: "No conversations yet. Start one from the right.",
325
+ count: "{n} total",
326
+ live_with: "Live · {slug}",
327
+ live_subtitle: "In-memory session",
328
+ },
319
329
  },
320
330
 
321
331
  tasks: {
@@ -276,7 +276,6 @@ export const es = {
276
276
  tasks: "Tasks",
277
277
  mcps: "MCPs",
278
278
  vars: "Variables",
279
- threads: "Chats",
280
279
  logs: "Logs",
281
280
  memories: "Memorias",
282
281
  },
@@ -302,6 +301,7 @@ export const es = {
302
301
  subtitle: "Chat directo con el agente del proyecto.",
303
302
  superagent_title: "Chat con {persona}",
304
303
  superagent_subtitle: "Chat con {persona} — el super-agente APX. Puede usar tools (proyectos, tasks, mcps, agentes).",
304
+ loaded_subtitle: "Conversación cargada con {slug}. Lo que mandes se agrega a este chat.",
305
305
  empty: "Mandá un mensaje para arrancar la conversación.",
306
306
  placeholder: "Escribí algo y enter para enviar (shift+enter = nueva línea)",
307
307
  send: "Enviar",
@@ -317,6 +317,16 @@ export const es = {
317
317
  model_label: "modelo",
318
318
  model_hint: "ej. openai:gpt-5, groq:llama-3.3-70b-versatile",
319
319
  master_label: "Agente master",
320
+ list: {
321
+ title: "Chats",
322
+ new: "Nuevo",
323
+ search: "Buscar chats…",
324
+ all_agents: "Todos los agentes",
325
+ empty: "No hay conversaciones todavía. Arrancá una desde la derecha.",
326
+ count: "{n} en total",
327
+ live_with: "Live · {slug}",
328
+ live_subtitle: "Sesión en memoria",
329
+ },
320
330
  },
321
331
 
322
332
  tasks: {
@@ -10,7 +10,7 @@ export const Agents = {
10
10
  http.patch<AgentEntry>(`/projects/${pid}/agents/${encodeURIComponent(slug)}`, body),
11
11
  remove: (pid: string, slug: string) =>
12
12
  http.del<{ ok: boolean }>(`/projects/${pid}/agents/${encodeURIComponent(slug)}`),
13
- chat: (pid: string, slug: string, body: { prompt: string; conversation_id?: string; model?: string }) =>
13
+ chat: (pid: string, slug: string, body: { prompt: string; conversation_id?: string; model?: string; channel?: string }) =>
14
14
  http.post<{ conversation_id: string; text: string; usage?: unknown; engine: string }>(
15
15
  `/projects/${pid}/agents/${encodeURIComponent(slug)}/chat`,
16
16
  body,
@@ -1,5 +1,5 @@
1
1
  import { useMemo } from "react";
2
- import { useParams, Routes, Route, useLocation, useNavigate } from "react-router-dom";
2
+ import { useParams, Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom";
3
3
  import {
4
4
  Bot, Heart, Zap, Puzzle, FolderKanban, Settings,
5
5
  MessagesSquare, Send, KeyRound,
@@ -23,7 +23,6 @@ import { RoutinesTab } from "./project/RoutinesTab";
23
23
  import { TasksTab } from "./project/TasksTab";
24
24
  import { McpsTab } from "./project/McpsTab";
25
25
  import { VarsTab } from "./project/VarsTab";
26
- import { ThreadsTab } from "./project/ThreadsTab";
27
26
  import { ChatTab } from "./project/ChatTab";
28
27
  import { TelegramTab } from "./project/TelegramTab";
29
28
  import { MemoriesTab } from "./project/MemoriesTab";
@@ -31,7 +30,7 @@ import { AgentDetailScreen } from "./project/AgentDetailScreen";
31
30
 
32
31
  type NavKey =
33
32
  | "" | "chat" | "config" | "telegram"
34
- | "agents" | "routines" | "tasks" | "mcps" | "vars" | "threads" | "logs" | "memories";
33
+ | "agents" | "routines" | "tasks" | "mcps" | "vars" | "logs" | "memories";
35
34
 
36
35
  export function ProjectScreen() {
37
36
  const navigate = useNavigate();
@@ -83,7 +82,6 @@ export function ProjectScreen() {
83
82
  { key: "", label: t("project.nav.overview"), icon: FolderKanban },
84
83
  { key: "telegram", label: t("project.nav.telegram"), icon: Send },
85
84
  { key: "chat", label: t("project.nav.chat"), icon: MessagesSquare },
86
- { key: "threads", label: t("project.nav.threads"), icon: MessagesSquare },
87
85
  { key: "agents", label: t("project.nav.agents"), icon: Bot },
88
86
  { key: "memories", label: t("project.nav.memories"), icon: Brain },
89
87
  ],
@@ -146,7 +144,7 @@ export function ProjectScreen() {
146
144
  <Route path="tasks" element={isBase ? <GlobalTasksTab /> : <TasksTab pid={pid} />} />
147
145
  <Route path="mcps" element={<McpsTab pid={pid} />} />
148
146
  <Route path="vars" element={<VarsTab pid={pid} />} />
149
- <Route path="threads" element={<ThreadsTab pid={pid} />} />
147
+ <Route path="threads" element={<Navigate to={`/p/${pid}/chat`} replace />} />
150
148
  <Route path="chat" element={<ChatTab pid={pid} />} />
151
149
  <Route path="*" element={<Overview pid={pid} />} />
152
150
  </Routes>
@@ -52,9 +52,11 @@ const SECTIONS: TabSection[] = [
52
52
  },
53
53
  ];
54
54
 
55
- // Tabs whose content (model router + provider grid, telegram tabs) benefits
56
- // from a wider container; the rest keep the cosier 3xl reading width.
57
- const WIDE_TABS = new Set<TabKey>(["engines", "telegram"]);
55
+ // Tabs whose content lays out multiple top-level sections in a two-column grid
56
+ // on xl (and so wants full available width). Single-section panels (identity,
57
+ // super agent, devices, advanced) keep a cosier reading width so wide displays
58
+ // don't blow form fields up to absurd widths.
59
+ const WIDE_TABS = new Set<TabKey>(["engines", "telegram", "memory", "skills", "appearance"]);
58
60
 
59
61
  const PANELS: Record<TabKey, () => ReactElement> = {
60
62
  identity: () => <IdentityPanel />,
@@ -84,7 +86,7 @@ export function SettingsScreen() {
84
86
  onChange={(k) => navigate(k === "identity" ? "/settings" : `/settings/${pathFromTab(k as TabKey)}`)}
85
87
  collapsed={collapsed}
86
88
  onToggleCollapse={toggle}
87
- contentClassName={`mx-auto w-full ${WIDE_TABS.has(active) ? "max-w-6xl" : "max-w-3xl"} space-y-6 p-6 pt-3`}
89
+ contentClassName={`w-full ${WIDE_TABS.has(active) ? "" : "mx-auto max-w-3xl"} space-y-6 p-6 pt-3`}
88
90
  testId={`settings-tab-${active}`}
89
91
  >
90
92
  <Panel />