Skip to content
Open
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
4 changes: 4 additions & 0 deletions web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,13 @@
"chat_queue_pause": "Pause",
"chat_queue_pause_queue": "Pause queue",
"chat_queue_paused": "Queue paused",
"chat_queue_pending_count": "{count} queued",
"chat_queue_pending_inputs": "Queued inputs",
"chat_queue_remove_from_queue": "Remove from queue",
"chat_queue_resume": "Resume",
"chat_queue_resume_queue": "Resume queue",
"chat_queue_sending": "Sending",
"chat_queue_more_pending": "{count} more queued",
"chat_tool_display_copy_to_clipboard": "Copy to clipboard",
"chat_tool_display_jump_to_results": "Jump to results",
"chat_tool_web_fetch_instruction": "Instruction",
Expand Down
45 changes: 44 additions & 1 deletion web/src/lib/chat/__tests__/composer.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
// Unit tests for ComposerState class. Tests synchronous state management;
// submitMessage and localStorage-dependent draft methods are not tested here.

import { describe, it, expect } from 'vitest';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { ComposerState } from '../composer.svelte';

describe('ComposerState', () => {
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
});

it('starts with empty state', () => {
const state = new ComposerState();
expect(state.inputText).toBe('');
Expand Down Expand Up @@ -56,4 +61,42 @@ describe('ComposerState', () => {
expect(state.inputText).toBe('');
expect(state.images).toEqual([]);
});

it('debounces draft writes and persists the latest queued text', () => {
vi.useFakeTimers();
const composer = new ComposerState();
composer.inputText = 'first';
composer.queueDraftSave('chat-1');
composer.inputText = 'second';
composer.queueDraftSave('chat-1');

expect(localStorage.getItem('chat_draft_chat-1')).toBeNull();
vi.advanceTimersByTime(250);

expect(localStorage.getItem('chat_draft_chat-1')).toBe('second');
});

it('does not let an old chat draft save after restore changes the input', () => {
vi.useFakeTimers();
const composer = new ComposerState();
composer.inputText = 'old chat text';
composer.queueDraftSave('old-chat');

composer.restoreDraft('new-chat');
composer.inputText = 'new chat text';
vi.runAllTimers();

expect(localStorage.getItem('chat_draft_old-chat')).toBeNull();
});

it('flushes a pending draft immediately', () => {
vi.useFakeTimers();
const composer = new ComposerState();
composer.inputText = 'draft body';
composer.queueDraftSave('chat-2');

composer.flushDraftSave();

expect(localStorage.getItem('chat_draft_chat-2')).toBe('draft body');
});
});
77 changes: 77 additions & 0 deletions web/src/lib/chat/__tests__/conversation-feed-items.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
AssistantMessage,
BashToolUseMessage,
ReadToolUseMessage,
ToolResultMessage,
UserMessage,
} from '$shared/chat-types'
import { buildConversationFeedRenderItems } from '../conversation-feed-items'

const TS = '2026-05-29T00:00:00.000Z'

describe('buildConversationFeedRenderItems', () => {
it('groups adjacent bash tool uses into one render item', () => {
const messages = [
new UserMessage(TS, 'start'),
new BashToolUseMessage(TS, 'bash-1', 'pwd'),
new BashToolUseMessage(TS, 'bash-2', 'rg foo'),
new AssistantMessage(TS, 'done'),
]

const items = buildConversationFeedRenderItems(messages)

expect(items).toHaveLength(3)
expect(items[1]).toMatchObject({ kind: 'bash-group' })
if (items[1].kind !== 'bash-group') throw new Error('expected bash group')
expect(items[1].messages.map((message) => message.command)).toEqual(['pwd', 'rg foo'])
expect(items[2]).toMatchObject({ kind: 'message', prevMessage: messages[2] })
})

it('keeps a single bash tool use as a normal message', () => {
const messages = [
new UserMessage(TS, 'start'),
new BashToolUseMessage(TS, 'bash-1', 'pwd'),
new ReadToolUseMessage(TS, 'read-1', '/tmp/file.ts'),
]

const items = buildConversationFeedRenderItems(messages)

expect(items).toHaveLength(3)
expect(items[1]).toMatchObject({ kind: 'message', message: messages[1] })
})

it('groups bash tool uses across hidden tool results', () => {
const messages = [
new BashToolUseMessage(TS, 'bash-1', 'pwd'),
new ToolResultMessage(TS, 'bash-1', { content: 'ok' }, false),
new BashToolUseMessage(TS, 'bash-2', 'rg foo'),
new ToolResultMessage(TS, 'bash-2', { content: 'ok' }, false),
]

const items = buildConversationFeedRenderItems(messages)

expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({ kind: 'bash-group' })
if (items[0].kind !== 'bash-group') throw new Error('expected bash group')
expect(items[0].messages.map((message) => message.toolId)).toEqual(['bash-1', 'bash-2'])
})

it('keeps the group id stable as more adjacent bash tool uses arrive', () => {
const firstBatch = [
new BashToolUseMessage(TS, 'bash-1', 'pwd'),
new BashToolUseMessage(TS, 'bash-2', 'rg foo'),
]
const secondBatch = [
...firstBatch,
new BashToolUseMessage(TS, 'bash-3', 'bun run test'),
]

const firstItems = buildConversationFeedRenderItems(firstBatch)
const secondItems = buildConversationFeedRenderItems(secondBatch)

expect(firstItems[0]).toMatchObject({ kind: 'bash-group' })
expect(secondItems[0]).toMatchObject({ kind: 'bash-group' })
expect(firstItems[0].id).toBe(secondItems[0].id)
})
})
51 changes: 51 additions & 0 deletions web/src/lib/chat/__tests__/conversation-scroll-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,57 @@ describe('ConversationScrollController', () => {
cleanup?.();
});

it('preserves the viewport anchor after older messages render', async () => {
const scroller = { scrollTop: 40, scrollHeight: 800, clientHeight: 400 } as HTMLDivElement;
const chatState = {
isUserScrolledUp: true,
loadMoreMessages: vi.fn(async () => {
Object.defineProperty(scroller, 'scrollHeight', { value: 1100, configurable: true });
return true;
}),
};

const controller = new ConversationScrollController({
getScrollContainer: () => scroller,
getQueueContainer: () => undefined,
chatState: chatState as never,
sessions: { selectedChatId: 'chat-1' },
});

controller.isPinnedToBottom = false;
await controller.loadMoreMessagesPreservingAnchor('chat-1', 800, 40);

expect(chatState.loadMoreMessages).toHaveBeenCalledWith('chat-1');
expect(scroller.scrollTop).toBe(340);
expect(chatState.isUserScrolledUp).toBe(true);
expect(controller.isPinnedToBottom).toBe(false);
});

it('does not restore an older-message anchor after switching chats', async () => {
const scroller = { scrollTop: 40, scrollHeight: 800, clientHeight: 400 } as HTMLDivElement;
const sessions = { selectedChatId: 'chat-1' };
const chatState = {
isUserScrolledUp: true,
loadMoreMessages: vi.fn(async () => {
sessions.selectedChatId = 'chat-2';
Object.defineProperty(scroller, 'scrollHeight', { value: 1100, configurable: true });
return true;
}),
};

const controller = new ConversationScrollController({
getScrollContainer: () => scroller,
getQueueContainer: () => undefined,
chatState: chatState as never,
sessions,
});

await controller.loadMoreMessagesPreservingAnchor('chat-1', 800, 40);

expect(scroller.scrollTop).toBe(40);
expect(chatState.loadMoreMessages).toHaveBeenCalledWith('chat-1');
});

it('keeps the viewport pinned to bottom when the scroll container height changes', () => {
const scrollToBottom = vi.spyOn(ConversationScrollController.prototype, 'scrollToBottom');
const scroller = { scrollTop: 120, scrollHeight: 800, clientHeight: 520 } as HTMLDivElement;
Expand Down
68 changes: 56 additions & 12 deletions web/src/lib/chat/composer.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,78 @@
// Composer state: input text, image attachments, draft persistence,
// and message submission. Manages the input area lifecycle for a single chat.

const DRAFT_PREFIX = 'chat_draft_';
const DRAFT_PREFIX = 'chat_draft_';
const DEFAULT_DRAFT_SAVE_DELAY_MS = 250;

function draftKey(chatId: string): string {
return `${DRAFT_PREFIX}${chatId}`;
}

function writeDraft(chatId: string, text: string): void {
if (!chatId) return;
const key = draftKey(chatId);
try {
if (text.trim()) {
localStorage.setItem(key, text);
} else {
localStorage.removeItem(key);
}
} catch {
// Storage can be full or unavailable.
}
}

export class ComposerState {
inputText = $state('');
images = $state<File[]>([]);
isSubmitting = $state(false);
isDragActive = $state(false);
#draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
#pendingDraftSave: { chatId: string; text: string } | null = null;

/** Saves the current input text as a draft keyed by chat ID. */
saveDraft(chatId: string): void {
writeDraft(chatId, this.inputText);
}

/** Schedules draft persistence without blocking every input event. */
queueDraftSave(chatId: string, delayMs = DEFAULT_DRAFT_SAVE_DELAY_MS): void {
if (!chatId) return;
const key = `${DRAFT_PREFIX}${chatId}`;
try {
if (this.inputText.trim()) {
localStorage.setItem(key, this.inputText);
} else {
localStorage.removeItem(key);
}
} catch {
// Storage full or unavailable
this.cancelDraftSave();
this.#pendingDraftSave = { chatId, text: this.inputText };
this.#draftSaveTimer = setTimeout(() => {
this.flushDraftSave();
}, delayMs);
}

/** Persists the latest queued draft immediately. */
flushDraftSave(): void {
if (this.#draftSaveTimer) {
clearTimeout(this.#draftSaveTimer);
this.#draftSaveTimer = null;
}
const pending = this.#pendingDraftSave;
this.#pendingDraftSave = null;
if (pending) writeDraft(pending.chatId, pending.text);
}

/** Drops a queued draft write, optionally scoped to one chat. */
cancelDraftSave(chatId?: string): void {
if (chatId && this.#pendingDraftSave?.chatId !== chatId) return;
if (this.#draftSaveTimer) {
clearTimeout(this.#draftSaveTimer);
this.#draftSaveTimer = null;
}
this.#pendingDraftSave = null;
}

/** Restores a previously saved draft for the given chat ID. */
restoreDraft(chatId: string): void {
this.cancelDraftSave();
this.inputText = '';
this.clearImages();
if (!chatId) return;
const key = `${DRAFT_PREFIX}${chatId}`;
const key = draftKey(chatId);
try {
const saved = localStorage.getItem(key);
if (saved) {
Expand All @@ -43,7 +86,7 @@ export class ComposerState {
/** Removes the saved draft for the given chat ID. */
clearDraft(chatId: string): void {
if (!chatId) return;
const key = `${DRAFT_PREFIX}${chatId}`;
const key = draftKey(chatId);
try {
localStorage.removeItem(key);
} catch {
Expand All @@ -70,6 +113,7 @@ export class ComposerState {

/** Resets input text, images, and draft for the given chat. */
clearAfterSubmit(chatId: string): void {
this.cancelDraftSave(chatId);
this.inputText = '';
this.images = [];
this.clearDraft(chatId);
Expand Down
Loading