Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion frontend/src/components/assistant/AssistantRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useAuth, registerSignOutListener } from "@/contexts/AuthContext";
import { useAssistantUI } from "@/contexts/AssistantUIContext";
import { useAssistantConfig } from "./useAssistantConfig";
import { AssistantPanel } from "./AssistantPanel";
import { clearAssistantHistory } from "./useAssistantChat";
import "./i18n";

/**
Expand Down Expand Up @@ -31,7 +32,15 @@ export default function AssistantRoot() {
[setAvailable, closeAssistant]
);

useEffect(() => registerSignOutListener(closeAssistant), [closeAssistant]);
// One listener for both, so a later user in the same tab never sees these answers.
useEffect(
() =>
registerSignOutListener(() => {
clearAssistantHistory();
closeAssistant();
}),
[closeAssistant]
);

if (!available) {
return null;
Expand Down
169 changes: 148 additions & 21 deletions frontend/src/components/assistant/useAssistantChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type { AnswerChecks, ChatTurn, SourceRef, TurnTiming } from "@/types/api"
import { streamAssistantMessage } from "./assistant-api";
import { readAssistantSseStream, type AssistantSseEvent } from "./sse";

export type AssistantMessageStatus = "pending" | "done" | "error" | "cut" | "cancelled";
const MESSAGE_STATUSES = ["pending", "done", "error", "cut", "cancelled"] as const;

export type AssistantMessageStatus = (typeof MESSAGE_STATUSES)[number];

export interface AssistantMessageError {
readonly code: number;
Expand All @@ -27,14 +29,102 @@ export interface AssistantMessage {
readonly createdAt: number;
}

/** sessionStorage keys are `${STORAGE_PREFIX}:${userId}:${projectId ?? "general"}`. */
export const STORAGE_PREFIX = "assistant-chat";

// AssistantChatRequest.history takes at most 20 entries (api/schemas/assistant.py).
const MAX_HISTORY_ENTRIES = 20;
// The most messages kept in a conversation.
// The list kept in memory, and the same number is what is stored.
const MAX_MESSAGES = 40;

// Bumped by clearAssistantHistory(). A thread remembers the value it was loaded
// under and stops writing once that value is out of date, so a sign-out cannot be
// undone by a hook that is still mounted when it happens.
let historyEpoch = 0;

interface Thread {
readonly key: string;
readonly messages: AssistantMessage[];
readonly epoch: number;
// How many times an action (append, settle, clear) asked for the thread to be
// stored. It travels inside the state with the change it belongs to, so no render
// can take the request for a save without also holding that change.
readonly writes: number;
}

function storageKeyFor(userId: string, projectId: string | null): string {
return `${STORAGE_PREFIX}:${userId}:${projectId ?? "general"}`;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isStoredMessage(value: unknown): value is AssistantMessage {
if (!isRecord(value)) return false;
const { error, sources, toolsUsed, checks, timing } = value;
return (
typeof value.id === "string" &&
(value.role === "user" || value.role === "assistant") &&
typeof value.content === "string" &&
typeof value.status === "string" &&
(MESSAGE_STATUSES as readonly string[]).includes(value.status) &&
typeof value.createdAt === "number" &&
(error === undefined ||
(isRecord(error) &&
typeof error.code === "number" &&
typeof error.detail === "string" &&
(error.retryAfter === undefined || typeof error.retryAfter === "number"))) &&
(sources === undefined || Array.isArray(sources)) &&
(toolsUsed === undefined || Array.isArray(toolsUsed)) &&
(checks === undefined || isRecord(checks)) &&
(timing === undefined || isRecord(timing))
);
}

// A request cannot outlive the page that made it, so a message stored as pending
// is a partial answer that was cut off, empty or not.
function loadMessages(key: string): AssistantMessage[] {
try {
const parsed: unknown = JSON.parse(window.sessionStorage.getItem(key) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter(isStoredMessage)
.slice(-MAX_MESSAGES)
.map((m) => (m.status === "pending" ? { ...m, status: "cut" } : m));
} catch {
return [];
}
}

// Storage that is blocked, full or absent must never throw out of the hook.
function saveMessages({ key, messages, epoch }: Thread): void {
if (epoch !== historyEpoch) return;
try {
if (messages.length === 0) {
window.sessionStorage.removeItem(key);
} else {
window.sessionStorage.setItem(key, JSON.stringify(messages));
}
} catch {
// The conversation simply is not kept across a reload.
}
}

/** Removes the conversations of every user and project, for sign-out. */
export function clearAssistantHistory(): void {
historyEpoch += 1;
try {
const storage = window.sessionStorage;
const keys: string[] = [];
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i);
if (key?.startsWith(`${STORAGE_PREFIX}:`)) keys.push(key);
}
keys.forEach((key) => storage.removeItem(key));
} catch {
// Nothing was stored that could be cleared.
}
}

// Only a message that was sent and answered in full goes back to the server: a
Expand Down Expand Up @@ -125,27 +215,46 @@ async function consumeStream(
if (!progress.finished) throw new StreamIncompleteError();
}

/**
* `userId` is the signed-in user the conversation belongs to. The hook does not read
* it: a conversation lives in memory and is told apart by project alone.
*/
// A thread that no longer holds the message (cleared, or another conversation) is
// left as it is.
function patchThread(
thread: Thread,
id: string,
patch: (m: AssistantMessage) => AssistantMessage,
settles: boolean
): Thread {
if (!thread.messages.some((m) => m.id === id)) return thread;
return {
...thread,
messages: patchMessage(thread.messages, id, patch),
writes: thread.writes + (settles ? 1 : 0),
};
}

export function useAssistantChat({
projectId,
userId,
}: {
readonly projectId: string | null;
readonly userId: string;
}) {
const threadKey = projectId ?? "general";
const [thread, setThread] = useState<Thread>(() => ({ key: threadKey, messages: [] }));
const storageKey = storageKeyFor(userId, projectId);
const [thread, setThread] = useState<Thread>(() => ({
key: storageKey,
messages: loadMessages(storageKey),
epoch: historyEpoch,
writes: 0,
}));
const [draft, setDraft] = useState("");
const [now, setNow] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const savedRef = useRef({ key: storageKey, writes: 0 });

// A different project is a different conversation; the render that sees the
// new key swaps the thread before anything is committed.
// A different user or project is a different conversation; the render that
// sees the new key swaps the thread before anything is committed.
let current = thread;
if (thread.key !== threadKey) {
current = { key: threadKey, messages: [] };
if (thread.key !== storageKey) {
current = { key: storageKey, messages: loadMessages(storageKey), epoch: historyEpoch, writes: 0 };
setThread(current);
}
const { messages } = current;
Expand All @@ -155,26 +264,36 @@ export function useAssistantChat({
const startedAt = pendingMessage?.createdAt ?? null;
const pendingSeconds = startedAt === null ? 0 : Math.max(0, Math.floor((now - startedAt) / 1000));

// Stored after a turn is appended (the answer still empty), settles or is cleared,
// never on mount and never while tokens are arriving: a stream of any length
// writes storage twice.
useEffect(() => {
const saved = savedRef.current;
savedRef.current = { key: current.key, writes: current.writes };
if (saved.key !== current.key || saved.writes === current.writes) return;
saveMessages(current);
}, [current]);

useEffect(() => {
if (!isPending) return;
const interval = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(interval);
}, [isPending]);

// The Sheet keeps the panel mounted while it is open, so closing it does not
// cancel; the root going away, or the conversation changing under a request, does.
// cancel; the root going away, or the conversation changing under a request,
// does. The partial answer is dropped with it and is not written to storage.
useEffect(
() => () => {
abortRef.current?.abort();
},
[threadKey]
[storageKey]
);

const sendMessage = useCallback(async () => {
const text = draft.trim();
if (!text || abortRef.current) return;

const key = current.key;
const startedAtMs = Date.now();
const pendingId = crypto.randomUUID();
const history: ChatTurn[] = historyWindow(current.messages).map((index) => ({
Expand All @@ -196,8 +315,11 @@ export function useAssistantChat({
createdAt: startedAtMs,
};

const patchAnswer = (patch: (m: AssistantMessage) => AssistantMessage) =>
setThread((prev) => ({ ...prev, messages: patchMessage(prev.messages, pendingId, patch) }));
const patchAnswer = (patch: (m: AssistantMessage) => AssistantMessage, settles = false) =>
setThread((prev) => patchThread(prev, pendingId, patch, settles));
// The turn ends here, so what it leaves behind is worth storing.
const settleAnswer = (patch: (m: AssistantMessage) => AssistantMessage) =>
patchAnswer(patch, true);

// Nothing was answered, so the question goes back into the box, unless the
// user has already started typing another one. The question is marked as
Expand All @@ -207,13 +329,18 @@ export function useAssistantChat({
setThread((prev) => ({
...prev,
messages: patchMessage(prev.messages, userMessage.id, markFailed),
writes: prev.writes + 1,
}));
};
const progress: StreamProgress = { finished: false, hasContent: false };

const controller = new AbortController();
abortRef.current = controller;
setThread({ key, messages: [...current.messages, userMessage, answer].slice(-MAX_MESSAGES) });
setThread({
...current,
messages: [...current.messages, userMessage, answer].slice(-MAX_MESSAGES),
writes: current.writes + 1,
});
setDraft("");
setNow(startedAtMs);

Expand All @@ -225,7 +352,7 @@ export function useAssistantChat({
if (!response.body) throw new StreamIncompleteError();

await consumeStream(response.body, controller.signal, progress, (event) => {
patchAnswer((m) => applyEvent(m, event));
patchAnswer((m) => applyEvent(m, event), event.type !== "token");
if (event.type === "error" && !progress.hasContent) restoreDraft();
});
} catch (error) {
Expand All @@ -234,7 +361,7 @@ export function useAssistantChat({
if (!aborted) {
logger.error("Assistant turn failed", { error: error instanceof Error ? error.name : typeof error });
}
patchAnswer((m) => applyFailure(m, error, aborted, progress.hasContent));
settleAnswer((m) => applyFailure(m, error, aborted, progress.hasContent));
if (!aborted && !progress.hasContent) restoreDraft();
} finally {
if (abortRef.current === controller) abortRef.current = null;
Expand All @@ -248,7 +375,7 @@ export function useAssistantChat({
const clear = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setThread((prev) => ({ key: prev.key, messages: [] }));
setThread((prev) => ({ ...prev, messages: [], writes: prev.writes + 1 }));
}, []);

const sentIndexes = historyWindow(messages);
Expand Down
28 changes: 28 additions & 0 deletions frontend/tests/components/assistant/AssistantRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import i18n from '@/i18n';
import AssistantRoot from '@/components/assistant/AssistantRoot';
import { STORAGE_PREFIX } from '@/components/assistant/useAssistantChat';
import { AssistantUIProvider, useAssistantUI } from '@/contexts/AssistantUIContext';

const mockSetAvailable = vi.fn();
Expand Down Expand Up @@ -54,6 +55,7 @@ describe('AssistantRoot', () => {
mockUI = { isOpen: false, projectId: null };
signOutListener = undefined;
useRealUI = false;
window.sessionStorage.clear();
});

it('renders nothing and reports unavailable while the config query is loading', async () => {
Expand Down Expand Up @@ -128,6 +130,32 @@ describe('AssistantRoot', () => {
expect(mockUnsubscribe).toHaveBeenCalled();
});

it('forgets every stored conversation on sign-out and leaves other storage alone', () => {
mockUseAssistantConfig.mockReturnValue(enabledConfig);
window.sessionStorage.setItem(`${STORAGE_PREFIX}:u1:general`, '[]');
window.sessionStorage.setItem('unrelated', 'kept');
render(<AssistantRoot />);

signOutListener?.();

expect(window.sessionStorage.getItem(`${STORAGE_PREFIX}:u1:general`)).toBeNull();
expect(window.sessionStorage.getItem('unrelated')).toBe('kept');
});

it('forgets the stored conversations before it closes the panel', () => {
mockUseAssistantConfig.mockReturnValue(enabledConfig);
window.sessionStorage.setItem(`${STORAGE_PREFIX}:u1:general`, '[]');
let storedWhenClosed: string | null = 'not read';
mockCloseAssistant.mockImplementation(() => {
storedWhenClosed = window.sessionStorage.getItem(`${STORAGE_PREFIX}:u1:general`);
});
render(<AssistantRoot />);

signOutListener?.();

expect(storedWhenClosed).toBeNull();
});

it('registers the assistant i18n bundle for both locales', () => {
mockUseAssistantConfig.mockReturnValue({ isSuccess: false, data: undefined });

Expand Down
Loading