Skip to content

Commit 2f3b2ec

Browse files
committed
fix(webapp): the last-chat key stays org-true and a foreign chat heals instead of poisoning it
1 parent 4378c69 commit 2f3b2ec

5 files changed

Lines changed: 246 additions & 31 deletions

File tree

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 type { TurnActivity } from "./DashboardAgentMessages";
2128
import { DashboardAgentHeader } from "./DashboardAgentHeader";
@@ -27,23 +34,6 @@ import { agentPageLabel } from "./page-label";
2734
import { AgentPanelColumn } from "./panel-layout";
2835
import { concurrencyPath } from "~/utils/pathBuilder";
2936

30-
const lastChatStorageKey = (organizationId: string) =>
31-
`tdev:dashboard-agent:last-chat:${organizationId}`;
32-
33-
function readLastChat(storageKey: string): { chatId: string; path: string } | null {
34-
if (typeof window === "undefined") return null;
35-
try {
36-
const raw = window.localStorage.getItem(storageKey);
37-
if (!raw) return null;
38-
// Pre-path entries were the bare chat id: no page to match, so start fresh.
39-
if (!raw.startsWith("{")) return null;
40-
const parsed = JSON.parse(raw) as { chatId?: string; path?: string };
41-
return parsed.chatId && parsed.path ? { chatId: parsed.chatId, path: parsed.path } : null;
42-
} catch {
43-
return null;
44-
}
45-
}
46-
4737
function serializePageContext(pageContext: AgentPageContext): string | undefined {
4838
try {
4939
return JSON.stringify(pageContext);
@@ -54,6 +44,8 @@ function serializePageContext(pageContext: AgentPageContext): string | undefined
5444

5545
type ActiveChat = {
5646
chatId: string;
47+
// The org the chat belongs to, so a switch can't file it under the new org's key.
48+
organizationId: string;
5749
messages: UIMessage[];
5850
session: DashboardAgentSession | null;
5951
pendingFirstMessage?: string;
@@ -158,7 +150,13 @@ export function DashboardAgentPanel({
158150
const data = res.ok ? ((await res.json()) as OpenedChatResponse) : undefined;
159151
if (seq !== openChatRequestSeq.current) return;
160152
const opened = resolveOpenedChat(id, data);
161-
setActive(opened.kind === "gone" ? null : opened);
153+
if (opened.kind === "gone") {
154+
// Deleted, or another org's: drop the pointer so it can't be restored again.
155+
setActive(null);
156+
forgetLastChat(storageKey);
157+
return;
158+
}
159+
setActive({ ...opened, organizationId: organization.id });
162160
} catch (error) {
163161
console.error(`Dashboard agent: failed to open chat ${id}`, error);
164162
toast.error("We couldn't open that chat. Try again in a moment.");
@@ -167,7 +165,7 @@ export function DashboardAgentPanel({
167165
if (seq === openChatRequestSeq.current) setLoading(false);
168166
}
169167
},
170-
[actionPath, toast]
168+
[actionPath, organization.id, storageKey, toast]
171169
);
172170

173171
const createChat = useCallback(
@@ -200,6 +198,7 @@ export function DashboardAgentPanel({
200198
}
201199
setActive({
202200
chatId: data.chatId,
201+
organizationId: organization.id,
203202
messages: data.headStarted ? [userMessage] : [],
204203
session: { publicAccessToken: data.publicAccessToken },
205204
pendingFirstMessage: data.headStarted ? undefined : text,
@@ -213,7 +212,7 @@ export function DashboardAgentPanel({
213212
if (seq === openChatRequestSeq.current) setLoading(false);
214213
}
215214
},
216-
[actionPath, clientData, toast]
215+
[actionPath, clientData, organization.id, toast]
217216
);
218217

219218
const restored = useRef(false);
@@ -244,16 +243,9 @@ export function DashboardAgentPanel({
244243
}, [organization.id, loadHistory]);
245244

246245
useEffect(() => {
247-
if (!active?.chatId) return;
248-
try {
249-
window.localStorage.setItem(
250-
storageKey,
251-
JSON.stringify({ chatId: active.chatId, path: location.pathname })
252-
);
253-
} catch {
254-
/* ignore */
255-
}
256-
}, [active?.chatId, storageKey, location.pathname]);
246+
if (!shouldPersistLastChat(active, organization.id)) return;
247+
writeLastChat(storageKey, { chatId: active.chatId, path: location.pathname });
248+
}, [active, organization.id, storageKey, location.pathname]);
257249

258250
// Bound to its chat, which remounts with a fresh guard ref on every switch.
259251
const [prefill, setPrefill] = useState<{ text: string; seq: number; chatId: string } | undefined>(
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/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
@@ -110,7 +110,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
110110
getChatMessages(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }),
111111
getSession(dashboardAgentDb, { chatId, userId, organizationId: project.organizationId }),
112112
]);
113-
return json({ messages: messages ?? [], session });
113+
// Null is not an empty transcript: the chat is deleted or another org's, and a 200 would
114+
// read as a real, empty chat.
115+
if (messages === null) return json({ error: "Chat not found" }, { status: 404 });
116+
return json({ messages, session });
114117
}
115118

116119
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)