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
22 changes: 15 additions & 7 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { useSendMessage } from "@/domains/chat/hooks/use-send-message.js";
import { useInteractionActions } from "@/domains/chat/hooks/use-interaction-actions.js";
import { useEventStream } from "@/domains/chat/hooks/use-event-stream.js";
import { useActiveAppPinSync } from "@/domains/chat/hooks/use-active-app-pin-sync.js";
import { useDraftInput } from "@/domains/chat/components/chat-composer/use-draft-input.js";

import { createWebSyncRouter } from "@/lib/sync/web-sync-router.js";
import { fetchAssistantIdentity } from "@/assistant/identity.js";
Expand Down Expand Up @@ -127,7 +128,6 @@ export function ChatPage() {
// Local state
// -------------------------------------------------------------------------
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [input, setInput] = useState("");
const [error, setError] = useState<ChatError | null>(null);
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
const [compactionCircuitOpenUntil, setCompactionCircuitOpenUntil] = useState<Date | null>(null);
Expand Down Expand Up @@ -250,7 +250,6 @@ export function ChatPage() {
const loadEpochRef = useRef(0);
const pendingInitialMessageRef = useRef<{ conversationKey: string; content: string } | null>(null);
const expandedToolCallIdsRef = useRef<Set<string>>(new Set());
const draftsRef = useRef<Map<string, string>>(new Map());
const conversationCacheRef = useRef<Map<string, { messages: DisplayMessage[]; pagination: { hasMore: boolean; oldestTimestamp: number | null } }>>(new Map());
const contextWindowUsageByConversationRef = useRef<Map<string, ContextWindowUsage>>(new Map());
const refreshSettleRef = useRef<RefreshSettleHandle | null>(null);
Expand Down Expand Up @@ -289,6 +288,18 @@ export function ChatPage() {
status: diskPressure.status,
});

// -------------------------------------------------------------------------
// Draft input — owns composer `input` state and per-conversation draft
// persistence to localStorage. Replaces the old manual `draftsRef` that was
// threaded through useConversationLoader → useConversationHistory.
// -------------------------------------------------------------------------
const { input, setInput, saveDraft, clearDraft } = useDraftInput({
assistantId,
activeConversationKey,
draftKeyResolutionRef,
onDraftRestored: setRestoredDraftConversationKey,
});

// -------------------------------------------------------------------------
// Attachments
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -375,8 +386,6 @@ export function ChatPage() {
previousConversationKeyRef,
onboardingDraftConversationKeyRef,
activeConversationKeyRef,
inputRef,
draftsRef,
messagesRef,
contextWindowUsageByConversationRef,
dismissedSurfaceIdsRef,
Expand Down Expand Up @@ -404,10 +413,8 @@ export function ChatPage() {
setContextWindowUsage,
setSuggestion,
setCompactionCircuitOpenUntil,
setInput,
resetChatAttachments,
syncNeedsNewBubbleFromMessages,
onDraftRestored: setRestoredDraftConversationKey,
shouldSuppressGenericChatErrorNotice,
});

Expand Down Expand Up @@ -1067,6 +1074,8 @@ export function ChatPage() {
editingConversationKey,
restoredDraftConversationKey,
setRestoredDraftConversationKey,
saveDraft,
clearDraft,
avatar: {
avatarComponents: avatar.components,
avatarTraits: avatar.traits,
Expand Down Expand Up @@ -1216,7 +1225,6 @@ export function ChatPage() {
assistantIdRef,
streamContextRef,
expandedToolCallIdsRef,
draftsRef,
conversationCacheRef,
dismissedSurfaceIdsRef,
isLoadingOlderRef,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Exposes the internal localStorage helpers from useDraftInput for unit
* testing. These are not part of the public API.
*/

const STORAGE_KEY_PREFIX = "vellum:chatDrafts:";

function storageKey(assistantId: string): string {
return `${STORAGE_KEY_PREFIX}${assistantId}`;
}

export function loadDraftsForTest(assistantId: string): Map<string, string> {
try {
const raw = window.localStorage.getItem(storageKey(assistantId));
if (!raw) return new Map();
const parsed: unknown = JSON.parse(raw);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return new Map();
}
return new Map(
Object.entries(parsed as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
} catch {
return new Map();
}
}

export function persistDraftsForTest(
assistantId: string,
drafts: Map<string, string>,
): void {
try {
window.localStorage.setItem(
storageKey(assistantId),
JSON.stringify(Object.fromEntries(drafts)),
);
} catch {
// noop
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* Tests for useDraftInput localStorage helpers and switch-detection logic.
*
* The workspace lacks @testing-library/react (no jsdom), so we test the
* exported pure helpers and the localStorage serialization contract directly
* via a minimal localStorage shim.
*/

import { afterEach, beforeEach, describe, expect, test } from "bun:test";

// ---------------------------------------------------------------------------
// localStorage shim (Bun test env has no window.localStorage)
// ---------------------------------------------------------------------------

const store = new Map<string, string>();

const localStorageShim = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
clear: () => {
store.clear();
},
get length() {
return store.size;
},
key: (_index: number): string | null => null,
};

// Install the shim globally before importing the module under test so it
// can see `window.localStorage`.
if (typeof globalThis.window === "undefined") {
(globalThis as Record<string, unknown>).window = { localStorage: localStorageShim };
} else {
Object.defineProperty(globalThis.window, "localStorage", {
value: localStorageShim,
writable: true,
configurable: true,
});
}

// Dynamic import AFTER the shim is installed.
const { loadDraftsForTest, persistDraftsForTest } = await import(
"./use-draft-input-test-helpers.js"
);

// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------

beforeEach(() => {
store.clear();
});

afterEach(() => {
store.clear();
});

// ---------------------------------------------------------------------------
// loadDrafts
// ---------------------------------------------------------------------------

describe("loadDrafts", () => {
test("returns empty map when nothing stored", () => {
const result = loadDraftsForTest("ast-1");
expect(result.size).toBe(0);
});

test("round-trips through persist → load", () => {
const drafts = new Map([
["conv-a", "hello"],
["conv-b", "world"],
]);
persistDraftsForTest("ast-1", drafts);
const loaded = loadDraftsForTest("ast-1");
expect(loaded.size).toBe(2);
expect(loaded.get("conv-a")).toBe("hello");
expect(loaded.get("conv-b")).toBe("world");
});

test("ignores non-string values in stored JSON", () => {
store.set("vellum:chatDrafts:ast-1", JSON.stringify({
"conv-a": "valid",
"conv-b": 42,
"conv-c": null,
"conv-d": true,
}));
const loaded = loadDraftsForTest("ast-1");
expect(loaded.size).toBe(1);
expect(loaded.get("conv-a")).toBe("valid");
});

test("returns empty map for malformed JSON", () => {
store.set("vellum:chatDrafts:ast-1", "not json");
const loaded = loadDraftsForTest("ast-1");
expect(loaded.size).toBe(0);
});

test("returns empty map for non-object JSON (array)", () => {
store.set("vellum:chatDrafts:ast-1", '["a","b"]');
const loaded = loadDraftsForTest("ast-1");
expect(loaded.size).toBe(0);
});

test("scopes by assistantId", () => {
persistDraftsForTest("ast-1", new Map([["conv-a", "draft 1"]]));
persistDraftsForTest("ast-2", new Map([["conv-a", "draft 2"]]));
expect(loadDraftsForTest("ast-1").get("conv-a")).toBe("draft 1");
expect(loadDraftsForTest("ast-2").get("conv-a")).toBe("draft 2");
});
});

// ---------------------------------------------------------------------------
// persistDrafts
// ---------------------------------------------------------------------------

describe("persistDrafts", () => {
test("writes JSON object to localStorage", () => {
persistDraftsForTest("ast-1", new Map([["conv-a", "hello"]]));
const raw = store.get("vellum:chatDrafts:ast-1");
expect(raw).toBeDefined();
expect(JSON.parse(raw!)).toEqual({ "conv-a": "hello" });
});

test("overwrites previous drafts", () => {
persistDraftsForTest("ast-1", new Map([["conv-a", "first"]]));
persistDraftsForTest("ast-1", new Map([["conv-b", "second"]]));
const loaded = loadDraftsForTest("ast-1");
expect(loaded.size).toBe(1);
expect(loaded.has("conv-a")).toBe(false);
expect(loaded.get("conv-b")).toBe("second");
});

test("empty map writes empty object", () => {
persistDraftsForTest("ast-1", new Map());
const raw = store.get("vellum:chatDrafts:ast-1");
expect(JSON.parse(raw!)).toEqual({});
});
});

// ---------------------------------------------------------------------------
// Switch detection (pure logic)
// ---------------------------------------------------------------------------

describe("conversation switch detection", () => {
function isConversationSwitch(prevKey: string | null, nextKey: string | null): boolean {
return prevKey !== null && prevKey !== nextKey;
}

test("null → key is not a switch (initial mount)", () => {
expect(isConversationSwitch(null, "conv-a")).toBe(false);
});

test("same key is not a switch", () => {
expect(isConversationSwitch("conv-a", "conv-a")).toBe(false);
});

test("different keys IS a switch", () => {
expect(isConversationSwitch("conv-a", "conv-b")).toBe(true);
});

test("key → null IS a switch (save outgoing, no restore)", () => {
expect(isConversationSwitch("conv-a", null)).toBe(true);
});
});
Loading