Skip to content

Commit f6af05e

Browse files
committed
Merge branch 'feat/dashboard-agent-flows-watch' into feat/agent-storybook-gallery
2 parents 3237a77 + 955622a commit f6af05e

6 files changed

Lines changed: 248 additions & 33 deletions

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

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import {
1616
type DashboardAgentSession,
1717
} from "./DashboardAgentChat";
1818
import { createCoalescedReload } from "./coalesced-reload";
19+
import {
20+
forgetLastChat,
21+
lastChatStorageKey,
22+
readLastChat,
23+
shouldPersistLastChat,
24+
writeLastChat,
25+
} from "./last-chat-storage";
1926
import { DashboardAgentDraft } from "./DashboardAgentDraft";
2027
import { WatchCard } from "./WatchCard";
2128
import { watchDraftFor } from "./watch-card";
@@ -34,23 +41,6 @@ import { markChatListRead, unreadWorkCount } from "./unread-counts";
3441
import { AgentPanelColumn } from "./panel-layout";
3542
import { concurrencyPath } from "~/utils/pathBuilder";
3643

37-
const lastChatStorageKey = (organizationId: string) =>
38-
`tdev:dashboard-agent:last-chat:${organizationId}`;
39-
40-
function readLastChat(storageKey: string): { chatId: string; path: string } | null {
41-
if (typeof window === "undefined") return null;
42-
try {
43-
const raw = window.localStorage.getItem(storageKey);
44-
if (!raw) return null;
45-
// Pre-path entries were the bare chat id: no page to match, so start fresh.
46-
if (!raw.startsWith("{")) return null;
47-
const parsed = JSON.parse(raw) as { chatId?: string; path?: string };
48-
return parsed.chatId && parsed.path ? { chatId: parsed.chatId, path: parsed.path } : null;
49-
} catch {
50-
return null;
51-
}
52-
}
53-
5444
function serializePageContext(pageContext: AgentPageContext): string | undefined {
5545
try {
5646
return JSON.stringify(pageContext);
@@ -61,6 +51,8 @@ function serializePageContext(pageContext: AgentPageContext): string | undefined
6151

6252
type ActiveChat = {
6353
chatId: string;
54+
// The org the chat belongs to, so a switch can't file it under the new org's key.
55+
organizationId: string;
6456
messages: UIMessage[];
6557
session: DashboardAgentSession | null;
6658
pendingFirstMessage?: string;
@@ -208,7 +200,13 @@ export function DashboardAgentPanel({
208200
const data = res.ok ? ((await res.json()) as OpenedChatResponse) : undefined;
209201
if (seq !== openChatRequestSeq.current) return;
210202
const opened = resolveOpenedChat(id, data);
211-
setActive(opened.kind === "gone" ? null : opened);
203+
if (opened.kind === "gone") {
204+
// Deleted, or another org's: drop the pointer so it can't be restored again.
205+
setActive(null);
206+
forgetLastChat(storageKey);
207+
return;
208+
}
209+
setActive({ ...opened, organizationId: organization.id });
212210
} catch (error) {
213211
console.error(`Dashboard agent: failed to open chat ${id}`, error);
214212
toast.error("We couldn't open that chat. Try again in a moment.");
@@ -217,7 +215,7 @@ export function DashboardAgentPanel({
217215
if (seq === openChatRequestSeq.current) setLoading(false);
218216
}
219217
},
220-
[actionPath, claimChatSlot, toast]
218+
[actionPath, claimChatSlot, organization.id, storageKey, toast]
221219
);
222220

223221
const createChat = useCallback(
@@ -250,6 +248,7 @@ export function DashboardAgentPanel({
250248
}
251249
setActive({
252250
chatId: data.chatId,
251+
organizationId: organization.id,
253252
messages: data.headStarted ? [userMessage] : [],
254253
session: { publicAccessToken: data.publicAccessToken },
255254
pendingFirstMessage: data.headStarted ? undefined : text,
@@ -263,7 +262,7 @@ export function DashboardAgentPanel({
263262
if (seq === openChatRequestSeq.current) setLoading(false);
264263
}
265264
},
266-
[actionPath, claimChatSlot, clientData, toast]
265+
[actionPath, claimChatSlot, clientData, organization.id, toast]
267266
);
268267

269268
const restored = useRef(false);
@@ -306,16 +305,9 @@ export function DashboardAgentPanel({
306305
}, [openChatRequest, openChat]);
307306

308307
useEffect(() => {
309-
if (!active?.chatId) return;
310-
try {
311-
window.localStorage.setItem(
312-
storageKey,
313-
JSON.stringify({ chatId: active.chatId, path: location.pathname })
314-
);
315-
} catch {
316-
/* ignore */
317-
}
318-
}, [active?.chatId, storageKey, location.pathname]);
308+
if (!shouldPersistLastChat(active, organization.id)) return;
309+
writeLastChat(storageKey, { chatId: active.chatId, path: location.pathname });
310+
}, [active, organization.id, storageKey, location.pathname]);
319311

320312
useEffect(() => {
321313
if (!active?.chatId) return;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
forgetLastChat,
4+
lastChatStorageKey,
5+
readLastChat,
6+
shouldPersistLastChat,
7+
writeLastChat,
8+
} from "./last-chat-storage";
9+
10+
const ORG_A = "org_a";
11+
const ORG_B = "org_b";
12+
const KEY_A = lastChatStorageKey(ORG_A);
13+
const KEY_B = lastChatStorageKey(ORG_B);
14+
15+
const chatOfA = { chatId: "chat_a1", organizationId: ORG_A };
16+
17+
let store: Map<string, string>;
18+
19+
beforeEach(() => {
20+
store = new Map();
21+
vi.stubGlobal("window", {
22+
localStorage: {
23+
getItem: (key: string) => store.get(key) ?? null,
24+
setItem: (key: string, value: string) => void store.set(key, value),
25+
removeItem: (key: string) => void store.delete(key),
26+
},
27+
});
28+
});
29+
30+
afterEach(() => {
31+
vi.unstubAllGlobals();
32+
});
33+
34+
describe("shouldPersistLastChat", () => {
35+
it("persists a chat under its own org", () => {
36+
expect(shouldPersistLastChat(chatOfA, ORG_A)).toBe(true);
37+
});
38+
39+
// The org-reset effect clears `active` in a later flush, so the persistence effect runs
40+
// once with the previous org's chat and the new org's key.
41+
it("does not persist the previous org's chat once the org has switched", () => {
42+
expect(shouldPersistLastChat(chatOfA, ORG_B)).toBe(false);
43+
});
44+
45+
it("persists nothing when there is no chat", () => {
46+
expect(shouldPersistLastChat(null, ORG_A)).toBe(false);
47+
});
48+
});
49+
50+
describe("last chat storage across an org switch", () => {
51+
it("leaves the new org's key untouched when the panel still holds the old org's chat", () => {
52+
writeLastChat(KEY_A, { chatId: chatOfA.chatId, path: "/orgs/a/runs" });
53+
if (shouldPersistLastChat(chatOfA, ORG_B)) {
54+
writeLastChat(KEY_B, { chatId: chatOfA.chatId, path: "/orgs/b/runs" });
55+
}
56+
57+
expect(readLastChat(KEY_B)).toBeNull();
58+
expect(readLastChat(KEY_A)).toEqual({ chatId: chatOfA.chatId, path: "/orgs/a/runs" });
59+
});
60+
61+
it("forgets a pointer to a chat that is gone", () => {
62+
writeLastChat(KEY_A, { chatId: chatOfA.chatId, path: "/orgs/a/runs" });
63+
forgetLastChat(KEY_A);
64+
65+
expect(readLastChat(KEY_A)).toBeNull();
66+
expect(store.has(KEY_A)).toBe(false);
67+
});
68+
69+
it("ignores a pre-path entry that was just the chat id", () => {
70+
store.set(KEY_A, "chat_a1");
71+
72+
expect(readLastChat(KEY_A)).toBeNull();
73+
});
74+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export const lastChatStorageKey = (organizationId: string) =>
2+
`tdev:dashboard-agent:last-chat:${organizationId}`;
3+
4+
export function readLastChat(storageKey: string): { chatId: string; path: string } | null {
5+
if (typeof window === "undefined") return null;
6+
try {
7+
const raw = window.localStorage.getItem(storageKey);
8+
if (!raw) return null;
9+
// Pre-path entries were the bare chat id: no page to match, so start fresh.
10+
if (!raw.startsWith("{")) return null;
11+
const parsed = JSON.parse(raw) as { chatId?: string; path?: string };
12+
return parsed.chatId && parsed.path ? { chatId: parsed.chatId, path: parsed.path } : null;
13+
} catch {
14+
return null;
15+
}
16+
}
17+
18+
export function writeLastChat(storageKey: string, entry: { chatId: string; path: string }) {
19+
if (typeof window === "undefined") return;
20+
try {
21+
window.localStorage.setItem(storageKey, JSON.stringify(entry));
22+
} catch {
23+
/* ignore */
24+
}
25+
}
26+
27+
export function forgetLastChat(storageKey: string) {
28+
if (typeof window === "undefined") return;
29+
try {
30+
window.localStorage.removeItem(storageKey);
31+
} catch {
32+
/* ignore */
33+
}
34+
}
35+
36+
/**
37+
* The chat's own org, never the panel's: an org switch re-keys the storage entry in the same
38+
* effect flush that still holds the previous org's chat, which would file it under the new key.
39+
*/
40+
export function shouldPersistLastChat<T extends { chatId: string; organizationId: string }>(
41+
active: T | null | undefined,
42+
organizationId: string
43+
): active is T {
44+
return Boolean(active?.chatId) && active?.organizationId === organizationId;
45+
}

apps/webapp/app/components/dashboard-agent/report-sparkline.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ export function ReportFindingLine({
169169
return (
170170
<p className="grid grid-cols-[1rem_4.5rem_minmax(0,1fr)] items-start gap-x-2">
171171
<ReportSeverityIcon severity={severity} tone={tone} className="mt-0.5" />
172-
<span className="mt-px text-xs uppercase tracking-wide text-text-dimmed">{type}</span>
173-
<span className={cn("text-sm", bright ? "text-text-bright" : "text-text-dimmed")}>
172+
<span className="-mt-px text-xs uppercase tracking-wide text-text-dimmed">{type}</span>
173+
<span className={cn("-mt-0.5 text-sm", bright ? "text-text-bright" : "text-text-dimmed")}>
174174
{text}
175175
</span>
176176
</p>

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
167167
getChatMessages(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }),
168168
getSession(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }),
169169
]);
170-
return json({ messages: messages ?? [], session });
170+
// Null is not an empty transcript: the chat is deleted or another org's, and a 200 would
171+
// read as a real, empty chat.
172+
if (messages === null) return json({ error: "Chat not found" }, { status: 404 });
173+
return json({ messages, session });
171174
}
172175

173176
const chats = await listChats(dashboardAgentDb, {
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
getChatMessages: vi.fn(),
5+
getSession: vi.fn(),
6+
}));
7+
8+
vi.mock("~/db.server", () => ({ $replica: {}, prisma: {} }));
9+
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
10+
vi.mock("~/services/session.server", () => ({
11+
requireUser: async () => ({ id: "usr_real", admin: false, isImpersonating: false }),
12+
}));
13+
vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
14+
canAccessDashboardAgent: async () => true,
15+
}));
16+
vi.mock("~/models/project.server", () => ({
17+
findProjectBySlug: async () => ({
18+
id: "proj_real",
19+
organizationId: "org_real",
20+
externalRef: "proj_ref_real",
21+
}),
22+
}));
23+
vi.mock("~/models/runtimeEnvironment.server", () => ({ findEnvironmentBySlug: vi.fn() }));
24+
vi.mock("~/services/dashboardAgent.server", () => ({
25+
dashboardAgentApiOrigin: () => "https://api.trigger.dev",
26+
isDashboardAgentConfigured: () => true,
27+
mintDashboardAgentToken: vi.fn(),
28+
mintDashboardAgentUserActorToken: vi.fn(),
29+
resolveDashboardAgentRepoSnapshot: async () => null,
30+
startDashboardAgentSession: vi.fn(),
31+
}));
32+
vi.mock("~/services/dashboardAgentHeadStart.server", () => ({
33+
startDashboardAgentHeadStart: vi.fn(),
34+
}));
35+
vi.mock("~/services/dashboardAgentDb.server", () => ({ dashboardAgentDb: {} }));
36+
vi.mock("~/services/resolveTriggerUri.server", () => ({ resolveTriggerUri: () => null }));
37+
vi.mock("@internal/dashboard-agent-db", () => ({
38+
chatExists: vi.fn(),
39+
countUserMessages: vi.fn(),
40+
createChat: vi.fn(),
41+
getChatMessages: mocks.getChatMessages,
42+
getSession: mocks.getSession,
43+
listChatIdsWithOpenInvestigations: vi.fn(),
44+
listChats: vi.fn(),
45+
renameChat: vi.fn(),
46+
setChatPinned: vi.fn(),
47+
softDeleteChat: vi.fn(),
48+
}));
49+
vi.mock("~/services/logger.server", () => ({
50+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
51+
}));
52+
53+
import { resolveOpenedChat } from "~/components/dashboard-agent/opened-chat";
54+
import { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent";
55+
56+
function openChatRequest(chatId: string) {
57+
return loader({
58+
request: new Request(
59+
`https://app.trigger.dev/resources/orgs/acme/projects/api/env/dev/dashboard-agent?chatId=${chatId}`
60+
),
61+
params: { organizationSlug: "acme", projectParam: "api", envParam: "dev" },
62+
context: {},
63+
} as any);
64+
}
65+
66+
// `getChatMessages` returns null for a chat this org cannot see, and [] for one it can that
67+
// simply has no messages yet. The route must keep those apart.
68+
describe("dashboard agent loader — opening a chat", () => {
69+
beforeEach(() => {
70+
mocks.getChatMessages.mockReset();
71+
mocks.getSession.mockReset().mockResolvedValue(null);
72+
});
73+
74+
it("reports a chat belonging to another org as not found", async () => {
75+
mocks.getChatMessages.mockResolvedValue(null);
76+
77+
const response = await openChatRequest("chat_from_another_org");
78+
79+
expect(response.status).toBe(404);
80+
expect(await response.json()).toMatchObject({ error: "Chat not found" });
81+
});
82+
83+
it("still returns an empty transcript for a chat of this org that has no messages", async () => {
84+
mocks.getChatMessages.mockResolvedValue([]);
85+
86+
const response = await openChatRequest("chat_mine");
87+
88+
expect(response.status).toBe(200);
89+
expect(await response.json()).toMatchObject({ messages: [] });
90+
});
91+
92+
// What the client makes of the 404: a foreign chat is gone, not an empty chat to keep.
93+
it("resolves the not-found response as a gone chat", async () => {
94+
mocks.getChatMessages.mockResolvedValue(null);
95+
96+
const response = await openChatRequest("chat_from_another_org");
97+
const data = response.ok ? await response.json() : undefined;
98+
99+
expect(resolveOpenedChat("chat_from_another_org", data as any)).toEqual({ kind: "gone" });
100+
});
101+
});

0 commit comments

Comments
 (0)