@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.
@@ -120,7 +120,7 @@ export function VoiceScreen() {
120
120
  };
121
121
 
122
122
  return (
123
- <div className="mx-auto max-w-6xl p-6" data-testid="screen-voice">
123
+ <div className="p-6" data-testid="screen-voice">
124
124
  <div className="grid gap-6 xl:grid-cols-2">
125
125
  {/* Left: TTS providers */}
126
126
  <Section
@@ -4,11 +4,11 @@ import useSWR from "swr";
4
4
  import { Plus, Trash2 } from "lucide-react";
5
5
  import { Agents } from "../../lib/api";
6
6
  import { Badge, Button, Dialog, Empty, Field, Input, Loading, Switch } from "../../components/ui";
7
- import { UiSelect } from "../../components/UiSelect";
8
7
  import { Composer } from "../../components/chat/Composer";
9
8
  import { MessageList } from "../../components/chat/MessageList";
10
9
  import { ContextBar } from "../../components/chat/ContextBar";
11
10
  import { InlineAskPanel, pendingAskQuestions } from "../../components/chat/InlineAskPanel";
11
+ import { ChatList, type ChatKey } from "../../components/chat/ChatList";
12
12
  import { useChat } from "../../hooks/useChat";
13
13
  import { useToast } from "../../components/Toast";
14
14
  import { t } from "../../i18n";
@@ -24,48 +24,56 @@ export function ChatTab({ pid }: { pid: string }) {
24
24
  const toast = useToast();
25
25
  const [params] = useSearchParams();
26
26
  const agents = useSWR(`/projects/${pid}/agents`, () => Agents.list(pid));
27
- const [activeSlug, setActiveSlug] = useState(params.get("agent") || "");
28
27
  const [creating, setCreating] = useState(false);
29
28
  const [model, setModel] = useState("");
30
29
  const [dismissedAskKey, setDismissedAskKey] = useState<string | null>(null);
31
- const { msgs, send: sendChat, stop, clear, streaming } = useChat(pid, (m) => toast.error(m));
30
+ const { msgs, send: sendChat, stop, clear, load, streaming, conversationId } =
31
+ useChat(pid, (m) => toast.error(m));
32
32
  const persona = usePersonaName();
33
33
 
34
+ // Selection state — drives both the sidebar highlight and the right-pane
35
+ // header. Defaults to a live session with the super-agent so the chat works
36
+ // even on a brand-new project with zero agents and zero conversations.
37
+ const initialFromUrl = params.get("agent");
38
+ const [selected, setSelected] = useState<ChatKey>(
39
+ initialFromUrl
40
+ ? { kind: "live", agentSlug: initialFromUrl }
41
+ : { kind: "live", agentSlug: ROBY_SLUG },
42
+ );
43
+
34
44
  const agentList = agents.data || [];
35
- // Virtual options shown in the dropdown — the super-agent is always first,
36
- // then the real project agents. It works on every project (calls
37
- // /projects/:pid/super-agent/chat) so we expose it everywhere, not just /base.
38
45
  const isRoby = (slug: string | null | undefined) => slug === ROBY_SLUG;
39
- const dropdownOptions = useMemo(
40
- () => [
41
- { value: ROBY_SLUG, label: `${persona} (super-agent)` },
42
- ...agentList.map((a) => ({ value: a.slug, label: a.slug })),
43
- ],
44
- [agentList, persona],
45
- );
46
+
47
+ // The agent whose dropdown badge / model we show on the right header.
46
48
  const activeAgent = useMemo(
47
- () => agentList.find((a) => a.slug === activeSlug) || agentList[0],
48
- [agentList, activeSlug],
49
+ () =>
50
+ selected.kind === "live"
51
+ ? agentList.find((a) => a.slug === selected.agentSlug)
52
+ : agentList.find((a) => a.slug === selected.agentSlug),
53
+ [agentList, selected],
49
54
  );
50
- // Effective slug we'll send with: Roby if explicitly selected, or the first
51
- // real agent, or Roby when the project has no agents at all.
52
- const effectiveSlug = isRoby(activeSlug)
53
- ? ROBY_SLUG
54
- : (activeAgent?.slug || ROBY_SLUG);
55
- const activeIsRoby = effectiveSlug === ROBY_SLUG;
55
+ const activeIsRoby = isRoby(selected.agentSlug);
56
56
 
57
+ // Whenever the user picks a stored conversation, reload the in-memory chat
58
+ // with its persisted history. The hook itself binds the conversation_id so
59
+ // subsequent sends append to the same file.
57
60
  useEffect(() => {
58
- if (!activeSlug && activeAgent?.slug) setActiveSlug(activeAgent.slug);
59
- }, [activeAgent?.slug, activeSlug]);
60
-
61
- const resetConversation = () => clear();
61
+ if (selected.kind === "conv") {
62
+ void load(selected.agentSlug, selected.convId);
63
+ } else {
64
+ // Switching to a live session = drop any previously bound conversation.
65
+ if (conversationId) clear();
66
+ }
67
+ // eslint-disable-next-line react-hooks/exhaustive-deps
68
+ }, [selected.kind, selected.kind === "conv" ? selected.convId : selected.agentSlug]);
62
69
 
63
70
  const send = async (text: string) => {
64
- if (!activeIsRoby && !activeAgent) return;
65
- await sendChat(text, {
66
- model: model || undefined,
67
- agentSlug: activeIsRoby ? undefined : activeAgent!.slug,
68
- });
71
+ if (activeIsRoby) {
72
+ await sendChat(text, { model: model || undefined });
73
+ return;
74
+ }
75
+ if (!activeAgent) return;
76
+ await sendChat(text, { model: model || undefined, agentSlug: activeAgent.slug });
69
77
  };
70
78
 
71
79
  const copyToClipboard = async (text: string) => {
@@ -73,72 +81,97 @@ export function ChatTab({ pid }: { pid: string }) {
73
81
  catch { /* ignore */ }
74
82
  };
75
83
 
76
- if (agents.isLoading) return <Loading />;
84
+ const onNewChat = () => {
85
+ setSelected({ kind: "live", agentSlug: ROBY_SLUG });
86
+ clear();
87
+ };
77
88
 
89
+ const headerTitle = activeIsRoby
90
+ ? t("project.chat.superagent_title", { persona })
91
+ : selected.kind === "conv"
92
+ ? selected.convId
93
+ : t("project.chat.title");
78
94
  const headerSubtitle = activeIsRoby
79
95
  ? t("project.chat.superagent_subtitle", { persona })
80
- : t("project.chat.subtitle");
96
+ : selected.kind === "conv"
97
+ ? t("project.chat.loaded_subtitle", { slug: selected.agentSlug })
98
+ : t("project.chat.subtitle");
99
+
100
+ if (agents.isLoading) return <Loading />;
81
101
 
82
102
  return (
83
- <div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card/40">
84
- <header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
85
- <div className="min-w-0">
86
- <h2 className="text-sm font-semibold">
87
- {activeIsRoby ? t("project.chat.superagent_title", { persona }) : t("project.chat.title")}
88
- </h2>
89
- <p className="truncate text-[11px] text-muted-fg">{headerSubtitle}</p>
90
- </div>
91
- <div className="flex items-center gap-2">
92
- <div className="w-52">
93
- <UiSelect
94
- value={effectiveSlug}
95
- onChange={(v) => { setActiveSlug(v); resetConversation(); }}
96
- options={dropdownOptions}
97
- />
103
+ <div className="flex h-full overflow-hidden rounded-xl border border-border bg-card/40">
104
+ <ChatList
105
+ pid={pid}
106
+ agents={agentList}
107
+ superAgentSlug={ROBY_SLUG}
108
+ superAgentLabel={`${persona} (super-agent)`}
109
+ selected={selected}
110
+ onSelect={setSelected}
111
+ onNewChat={onNewChat}
112
+ />
113
+
114
+ <section className="flex min-w-0 flex-1 flex-col">
115
+ <header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
116
+ <div className="min-w-0">
117
+ <h2 className="truncate text-sm font-semibold">{headerTitle}</h2>
118
+ <p className="truncate text-[11px] text-muted-fg">{headerSubtitle}</p>
98
119
  </div>
99
- {activeIsRoby
100
- ? <Badge tone="success">super-agent</Badge>
101
- : activeAgent?.model && <Badge tone="info">{activeAgent.model}</Badge>}
102
- {!agentList.length && !activeIsRoby && (
103
- <Button variant="primary" size="sm" onClick={() => setCreating(true)}>
104
- <Plus size={14} /> {t("project.chat.create_agent")}
120
+ <div className="flex items-center gap-2">
121
+ {activeIsRoby ? (
122
+ <Badge tone="success">super-agent</Badge>
123
+ ) : (
124
+ activeAgent?.model && <Badge tone="info">{activeAgent.model}</Badge>
125
+ )}
126
+ {selected.kind === "conv" && <Badge tone="info">{conversationId || "…"}</Badge>}
127
+ {!agentList.length && !activeIsRoby && (
128
+ <Button variant="primary" size="sm" onClick={() => setCreating(true)}>
129
+ <Plus size={14} /> {t("project.chat.create_agent")}
130
+ </Button>
131
+ )}
132
+ <Button
133
+ variant="ghost"
134
+ size="sm"
135
+ disabled={streaming || msgs.length === 0}
136
+ onClick={onNewChat}
137
+ >
138
+ <Trash2 size={13} /> {t("project.chat.clear")}
105
139
  </Button>
140
+ </div>
141
+ </header>
142
+
143
+ <div className="flex-1 overflow-y-auto">
144
+ {msgs.length ? (
145
+ <MessageList msgs={msgs} onCopy={copyToClipboard} />
146
+ ) : (
147
+ <div className="flex h-full items-center justify-center p-8">
148
+ <p className="text-sm text-muted-fg">{t("project.chat.empty")}</p>
149
+ </div>
106
150
  )}
107
- <Button variant="ghost" size="sm" disabled={streaming || msgs.length === 0} onClick={resetConversation}>
108
- <Trash2 size={13} /> {t("project.chat.clear")}
109
- </Button>
110
151
  </div>
111
- </header>
112
- <div className="flex-1 overflow-y-auto">
113
- {msgs.length ? (
114
- <MessageList msgs={msgs} onCopy={copyToClipboard} />
115
- ) : (
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>
119
- )}
120
- </div>
121
- <ContextBar msgs={msgs} />
122
- {(() => {
123
- const pending = !streaming ? pendingAskQuestions(msgs) : null;
124
- if (!pending || pending.turnKey === dismissedAskKey) return null;
125
- return (
126
- <InlineAskPanel
127
- turnKey={pending.turnKey}
128
- questions={pending.questions}
129
- onSubmit={(compiled) => void send(compiled)}
130
- onDismiss={() => setDismissedAskKey(pending.turnKey)}
131
- disabled={streaming}
132
- />
133
- );
134
- })()}
135
- <Composer
136
- onSend={send}
137
- onStop={stop}
138
- streaming={streaming}
139
- model={model}
140
- onModelChange={setModel}
141
- />
152
+ <ContextBar msgs={msgs} />
153
+ {(() => {
154
+ const pending = !streaming ? pendingAskQuestions(msgs) : null;
155
+ if (!pending || pending.turnKey === dismissedAskKey) return null;
156
+ return (
157
+ <InlineAskPanel
158
+ turnKey={pending.turnKey}
159
+ questions={pending.questions}
160
+ onSubmit={(compiled) => void send(compiled)}
161
+ onDismiss={() => setDismissedAskKey(pending.turnKey)}
162
+ disabled={streaming}
163
+ />
164
+ );
165
+ })()}
166
+ <Composer
167
+ onSend={send}
168
+ onStop={stop}
169
+ streaming={streaming}
170
+ model={model}
171
+ onModelChange={setModel}
172
+ />
173
+ </section>
174
+
142
175
  <CreateAgentDialog
143
176
  open={creating}
144
177
  pid={pid}