Skip to content

Commit 8a37eda

Browse files
committed
feat(webapp,dashboard-agent): server-owned chat sessions
The dashboard agent now creates chat sessions server-side: the server generates the chat id and owns the chat record (bound to the user and org), and only issues a session token for a chat the requesting user owns. A new chat opens in a draft composer; the first message creates the session via chat.startHeadStart, so the client never chooses the id. Also hardens the repo-read tools with collision-resistant workspace keys and a realpath guard against symlink escapes.
1 parent 8211f09 commit 8a37eda

20 files changed

Lines changed: 909 additions & 231 deletions

.claude/skills/drizzle/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Pinned versions: **`drizzle-orm` ^0.45**, **`drizzle-kit` ^0.31** (dev), **`post
2121

2222
## Package layout
2323

24-
```
24+
```text
2525
internal-packages/dashboard-agent-db/
2626
drizzle.config.ts # drizzle-kit config (schema path, out dir, schemaFilter)
2727
drizzle/ # generated migrations (committed)

apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,9 @@ import { DashboardAgentPanel } from "./DashboardAgentPanel";
2121
export function DashboardAgent({
2222
children,
2323
hasAccess = false,
24-
headStartEnabled = false,
2524
}: {
2625
children: React.ReactNode;
2726
hasAccess?: boolean;
28-
headStartEnabled?: boolean;
2927
}) {
3028
const [open, setOpen] = useState(false);
3129

@@ -61,7 +59,7 @@ export function DashboardAgent({
6159
</ResizablePanel>
6260
<ResizableHandle id="dashboard-agent-handle" />
6361
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
64-
<DashboardAgentPanel onClose={() => setOpen(false)} headStartEnabled={headStartEnabled} />
62+
<DashboardAgentPanel onClose={() => setOpen(false)} />
6563
</ResizablePanel>
6664
</ResizablePanelGroup>
6765
);

apps/webapp/app/components/dashboard-agent/DashboardAgentChat.tsx

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export function DashboardAgentChat({
4242
projectSlug,
4343
environmentSlug,
4444
currentPage,
45-
headStartEnabled,
45+
pendingFirstMessage,
46+
streaming,
4647
onTurnSettled,
4748
}: {
4849
chatId: string;
@@ -54,19 +55,22 @@ export function DashboardAgentChat({
5455
projectSlug: string;
5556
environmentSlug: string;
5657
currentPage: string;
57-
headStartEnabled: boolean;
58+
// Cold start: send this first message through the transport once on mount to
59+
// trigger the turn. Undefined for head-started and resumed chats.
60+
pendingFirstMessage?: string;
61+
// Head start: the turn is already in flight, so hydrate the session as
62+
// streaming so the transport resumes `session.out` instead of treating it as
63+
// a settled session with nothing to reconnect to.
64+
streaming?: boolean;
5865
onTurnSettled: () => void;
5966
}) {
6067
const [input, setInput] = useState("");
6168

6269
const transport = useTriggerChatTransport<typeof dashboardAgent>({
6370
task: "dashboard-agent",
6471
baseURL: apiOrigin,
65-
// First turn of a brand-new chat streams step 1 from the same-origin
66-
// head-start route (which mints + injects the delegated token server-side
67-
// and boots the agent in parallel). Only when the server is head-start
68-
// capable; otherwise the first turn takes the normal cold-start path.
69-
headStart: headStartEnabled ? `${actionPath}/headstart` : undefined,
72+
// New chats are created server-side (the `create` action owns the id and
73+
// runs head start), so there's no client-driven head-start route here.
7074
// Redirect only the `in`/append to the same-origin proxy, which mints +
7175
// injects the delegated user token server-side. `baseURL` stays a string so
7276
// `out` (the long-lived SSE) keeps the SDK's realtime-host routing — we
@@ -82,7 +86,10 @@ export function DashboardAgentChat({
8286
[chatId]: {
8387
publicAccessToken: session.publicAccessToken,
8488
lastEventId: session.lastEventId,
85-
isStreaming: false,
89+
// Head-started chats are mid-turn, so mark the session streaming to
90+
// make the transport resume `session.out`. A settled session
91+
// (history) stays false — its transcript loads from the store.
92+
isStreaming: streaming ?? false,
8693
},
8794
}
8895
: undefined,
@@ -121,12 +128,23 @@ export function DashboardAgentChat({
121128
id: chatId,
122129
messages: initialMessages,
123130
transport,
124-
resume: !!session,
131+
// Resume an existing/head-started session's stream. A cold-start chat has a
132+
// session but nothing to resume yet — it sends its first message instead.
133+
resume: !!session && !pendingFirstMessage,
125134
});
126135

127136
const isStreaming = status === "streaming";
128137
const isThinking = status === "submitted";
129138

139+
// Cold start: trigger the first turn by sending the pending message once.
140+
const sentFirst = useRef(false);
141+
useEffect(() => {
142+
if (pendingFirstMessage && !sentFirst.current) {
143+
sentFirst.current = true;
144+
void sendMessage({ text: pendingFirstMessage });
145+
}
146+
}, [pendingFirstMessage, sendMessage]);
147+
130148
const submit = useCallback(
131149
(text: string) => {
132150
const trimmed = text.trim();
@@ -146,7 +164,9 @@ export function DashboardAgentChat({
146164
// chat appears and titles/timestamps stay current.
147165
const prevStatus = useRef(status);
148166
useEffect(() => {
149-
if (prevStatus.current === "streaming" && status === "ready") onTurnSettled();
167+
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
168+
const nowSettled = status === "ready" || status === "error";
169+
if (wasInFlight && nowSettled) onTurnSettled();
150170
prevStatus.current = status;
151171
}, [status, onTurnSettled]);
152172

apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function DashboardAgentComposer({
2727
value={value}
2828
onChange={(e) => onChange(e.target.value)}
2929
onKeyDown={(e) => {
30-
if (e.key === "Enter" && !e.shiftKey) {
30+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
3131
e.preventDefault();
3232
onSubmit();
3333
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useCallback, useState } from "react";
2+
import { DashboardAgentComposer } from "./DashboardAgentComposer";
3+
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
4+
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
5+
6+
/**
7+
* The new-chat "draft" state: suggested prompts + composer with no transport
8+
* mounted and no chat id yet. The chat id is server-owned, so the first send
9+
* goes to the panel's `create` call, which generates the id and returns it;
10+
* only then does the real `DashboardAgentChat` mount. The client never invents
11+
* a chat id.
12+
*/
13+
export function DashboardAgentDraft({
14+
onSubmit,
15+
projectSlug,
16+
environmentSlug,
17+
currentPage,
18+
}: {
19+
onSubmit: (text: string) => void;
20+
projectSlug: string;
21+
environmentSlug: string;
22+
currentPage: string;
23+
}) {
24+
const [input, setInput] = useState("");
25+
26+
const submit = useCallback(
27+
(text: string) => {
28+
const trimmed = text.trim();
29+
if (!trimmed) return;
30+
setInput("");
31+
onSubmit(trimmed);
32+
},
33+
[onSubmit]
34+
);
35+
36+
return (
37+
<>
38+
<DashboardAgentContextBanner
39+
projectSlug={projectSlug}
40+
environmentSlug={environmentSlug}
41+
currentPage={currentPage}
42+
/>
43+
<DashboardAgentSuggestedPrompts onSelect={submit} />
44+
<DashboardAgentComposer
45+
value={input}
46+
onChange={setInput}
47+
onSubmit={() => submit(input)}
48+
onStop={() => {}}
49+
isStreaming={false}
50+
/>
51+
</>
52+
);
53+
}

apps/webapp/app/components/dashboard-agent/DashboardAgentHistory.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function DashboardAgentHistory({
6666
type="button"
6767
onClick={() => onDelete(chat.id)}
6868
aria-label="Delete chat"
69-
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100"
69+
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom"
7070
>
7171
<TrashIcon className="size-3.5" />
7272
</button>

apps/webapp/app/components/dashboard-agent/DashboardAgentPanel.tsx

Lines changed: 88 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type DashboardAgentClientData,
1414
type DashboardAgentSession,
1515
} from "./DashboardAgentChat";
16+
import { DashboardAgentDraft } from "./DashboardAgentDraft";
1617
import { DashboardAgentHeader } from "./DashboardAgentHeader";
1718
import {
1819
DashboardAgentHistory,
@@ -29,21 +30,23 @@ type ActiveChat = {
2930
chatId: string;
3031
messages: UIMessage[];
3132
session: DashboardAgentSession | null;
33+
// Cold start only: the agent run has no warm step-1, so the mounted chat sends
34+
// this first message through the transport to trigger the turn. Undefined for
35+
// head-started and resumed chats — their stream is resumed, not re-sent.
36+
pendingFirstMessage?: string;
37+
// True for a head-started chat: the turn is already in flight server-side, so
38+
// the transport must hydrate the session as streaming to resume `session.out`.
39+
streaming?: boolean;
3240
};
3341

3442
/**
3543
* The dashboard agent side panel. Owns history, the active chat, and last-chat
36-
* persistence; resolves a chat's stored transcript + session before mounting
37-
* the inner `DashboardAgentChat` (keyed by chatId) so resume flows in through
38-
* the transport's declarative `sessions` option.
44+
* persistence. New chats start in a draft state with no id; the server
45+
* generates the chat id on the first send (`create`) and owns the chat record,
46+
* so the client never invents an id. Existing chats resolve their stored
47+
* transcript + session before mounting `DashboardAgentChat` (keyed by chatId).
3948
*/
40-
export function DashboardAgentPanel({
41-
onClose,
42-
headStartEnabled = false,
43-
}: {
44-
onClose: () => void;
45-
headStartEnabled?: boolean;
46-
}) {
49+
export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
4750
const organization = useOrganization();
4851
const project = useProject();
4952
const environment = useEnvironment();
@@ -80,16 +83,17 @@ export function DashboardAgentPanel({
8083
}
8184
}, [actionPath]);
8285

83-
// Open a chat by id. A new chat mounts immediately with an empty transcript;
84-
// an existing one is fetched first so its session hydrates the transport at
85-
// mount. A stored id that's gone (deleted / never sent) falls back to fresh.
86+
// Bumped on each open so a slower earlier open can't overwrite a newer one
87+
// when chats are switched rapidly.
88+
const openChatRequestSeq = useRef(0);
89+
90+
// Open an existing chat: fetch its stored transcript + session so resume flows
91+
// in through the transport at mount. A stored id that's gone (deleted / never
92+
// sent) drops back to the draft state.
8693
const openChat = useCallback(
87-
async (id: string, opts?: { fetchExisting?: boolean }) => {
94+
async (id: string) => {
8895
setView("chat");
89-
if (!opts?.fetchExisting) {
90-
setActive({ chatId: id, messages: [], session: null });
91-
return;
92-
}
96+
const seq = ++openChatRequestSeq.current;
9397
setLoading(true);
9498
try {
9599
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
@@ -99,6 +103,7 @@ export function DashboardAgentPanel({
99103
session?: { publicAccessToken: string; lastEventId: string | null } | null;
100104
})
101105
: { messages: [], session: null };
106+
if (seq !== openChatRequestSeq.current) return;
102107
if (data.messages && data.messages.length > 0) {
103108
setActive({
104109
chatId: id,
@@ -111,16 +116,62 @@ export function DashboardAgentPanel({
111116
: null,
112117
});
113118
} else {
114-
setActive({ chatId: generateFriendlyId("chat"), messages: [], session: null });
119+
// Nothing stored under this id — drop to a fresh draft.
120+
setActive(null);
115121
}
116122
} finally {
117-
setLoading(false);
123+
if (seq === openChatRequestSeq.current) setLoading(false);
118124
}
119125
},
120126
[actionPath]
121127
);
122128

123-
// On open, restore the last chat (or start a new one). Runs once per mount.
129+
// Start a new chat by sending its first message. The server generates the id,
130+
// creates the chat record, and kicks off the first turn (head start when
131+
// configured, else a cold session). We then mount the real chat on the server
132+
// id and either resume its stream (head start) or send the message through
133+
// the transport (cold start).
134+
const createChat = useCallback(
135+
async (text: string) => {
136+
setView("chat");
137+
setLoading(true);
138+
try {
139+
const userMessage: UIMessage = {
140+
id: generateFriendlyId("msg"),
141+
role: "user",
142+
parts: [{ type: "text", text }],
143+
};
144+
const body = new FormData();
145+
body.set("intent", "create");
146+
body.set("message", JSON.stringify(userMessage));
147+
body.set("clientData", JSON.stringify(clientData));
148+
const res = await fetch(actionPath, { method: "POST", body });
149+
const data = (await res.json()) as {
150+
chatId?: string;
151+
publicAccessToken?: string;
152+
headStarted?: boolean;
153+
error?: string;
154+
};
155+
if (!res.ok || !data.chatId || !data.publicAccessToken) {
156+
setActive(null);
157+
return;
158+
}
159+
setActive({
160+
chatId: data.chatId,
161+
messages: data.headStarted ? [userMessage] : [],
162+
session: { publicAccessToken: data.publicAccessToken },
163+
pendingFirstMessage: data.headStarted ? undefined : text,
164+
streaming: data.headStarted,
165+
});
166+
} finally {
167+
setLoading(false);
168+
}
169+
},
170+
[actionPath, clientData]
171+
);
172+
173+
// On open, restore the last chat if there is one; otherwise stay in the draft
174+
// state (active = null). Runs once per mount.
124175
const restored = useRef(false);
125176
useEffect(() => {
126177
if (restored.current) return;
@@ -131,8 +182,7 @@ export function DashboardAgentPanel({
131182
} catch {
132183
/* localStorage unavailable — start fresh */
133184
}
134-
if (stored) void openChat(stored, { fetchExisting: true });
135-
else void openChat(generateFriendlyId("chat"));
185+
if (stored) void openChat(stored);
136186
}, [openChat, storageKey]);
137187

138188
// Persist the active chat as the one to restore next time.
@@ -146,12 +196,13 @@ export function DashboardAgentPanel({
146196
}, [active?.chatId, storageKey]);
147197

148198
const newChat = useCallback(() => {
149-
void openChat(generateFriendlyId("chat"));
150-
}, [openChat]);
199+
setView("chat");
200+
setActive(null);
201+
}, []);
151202

152203
const switchChat = useCallback(
153204
(id: string) => {
154-
void openChat(id, { fetchExisting: true });
205+
void openChat(id);
155206
},
156207
[openChat]
157208
);
@@ -192,25 +243,33 @@ export function DashboardAgentPanel({
192243
onNewChat={newChat}
193244
onDelete={deleteChat}
194245
/>
195-
) : loading || !active ? (
246+
) : loading ? (
196247
<div className="flex flex-1 items-center justify-center">
197248
<Spinner className="size-5" />
198249
</div>
199-
) : (
250+
) : active ? (
200251
<DashboardAgentChat
201252
key={active.chatId}
202253
chatId={active.chatId}
203254
initialMessages={active.messages}
204255
session={active.session}
256+
pendingFirstMessage={active.pendingFirstMessage}
257+
streaming={active.streaming}
205258
clientData={clientData}
206259
apiOrigin={apiOrigin}
207260
actionPath={actionPath}
208261
projectSlug={project.slug}
209262
environmentSlug={environment.slug}
210263
currentPage={currentPage}
211-
headStartEnabled={headStartEnabled}
212264
onTurnSettled={loadHistory}
213265
/>
266+
) : (
267+
<DashboardAgentDraft
268+
onSubmit={createChat}
269+
projectSlug={project.slug}
270+
environmentSlug={environment.slug}
271+
currentPage={currentPage}
272+
/>
214273
)}
215274
</div>
216275
);

0 commit comments

Comments
 (0)