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
27 changes: 26 additions & 1 deletion web/src/lib/chat/__tests__/conversation-feed-items.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import { describe, expect, it } from 'vitest';
import {
AssistantMessage,
BashToolUseMessage,
PermissionCancelledMessage,
PermissionResolvedMessage,
ReadToolUseMessage,
ToolResultMessage,
UserMessage,
} from '$shared/chat-types';
import { buildConversationFeedRenderItems } from '../conversation-feed-items';
import {
buildConversationFeedRenderItems,
buildConversationFeedRenderModel,
} from '../conversation-feed-items';

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

Expand Down Expand Up @@ -71,4 +76,24 @@ describe('buildConversationFeedRenderItems', () => {
expect(secondItems[0]).toMatchObject({ kind: 'bash-group' });
expect(firstItems[0].id).toBe(secondItems[0].id);
});

it('builds render items and terminal lookup indexes in one pass', () => {
const messages = [
new BashToolUseMessage(TS, 'bash-1', 'pwd'),
new ToolResultMessage(TS, 'bash-1', { content: 'ok' }, false),
new PermissionResolvedMessage(TS, 'perm-1', true),
new PermissionCancelledMessage(TS, 'perm-2', 'cancelled'),
new AssistantMessage(TS, 'done'),
];

const model = buildConversationFeedRenderModel(messages);

expect(model.items.map((item) => item.kind)).toEqual(['message', 'message']);
expect(model.toolResultIndex.get('bash-1')?.content).toEqual({ content: 'ok' });
expect(model.permissionTerminalById.get('perm-1')).toEqual({ state: 'resolved', allowed: true });
expect(model.permissionTerminalById.get('perm-2')).toEqual({
state: 'cancelled',
reason: 'cancelled',
});
});
});
41 changes: 38 additions & 3 deletions web/src/lib/chat/conversation-feed-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import {
} from '$shared/chat-types';
import type { ChatMessage } from '$shared/chat-types';

export interface PermissionTerminalState {
state: 'resolved' | 'cancelled';
allowed?: boolean;
reason?: string;
}

export type ConversationFeedRenderItem =
| {
kind: 'message';
Expand All @@ -23,6 +29,12 @@ export type ConversationFeedRenderItem =
prevMessage: ChatMessage | null;
};

export interface ConversationFeedRenderModel {
items: ConversationFeedRenderItem[];
toolResultIndex: Map<string, ToolResultMessage>;
permissionTerminalById: Map<string, PermissionTerminalState>;
}

function shouldSkipStandaloneMessage(message: ChatMessage): boolean {
return (
message instanceof ToolResultMessage ||
Expand All @@ -37,16 +49,32 @@ function bashGroupId(messages: BashToolUseMessage[]): string {
return `bash-group-${messages[0]?.toolId ?? 'empty'}`;
}

export function buildConversationFeedRenderItems(
export function buildConversationFeedRenderModel(
messages: ChatMessage[],
): ConversationFeedRenderItem[] {
): ConversationFeedRenderModel {
const items: ConversationFeedRenderItem[] = [];
const toolResultIndex = new Map<string, ToolResultMessage>();
const permissionTerminalById = new Map<string, PermissionTerminalState>();
let previousRenderable: ChatMessage | null = null;
let index = 0;

while (index < messages.length) {
const message = messages[index];

if (message instanceof ToolResultMessage) {
toolResultIndex.set(message.toolId, message)
} else if (message instanceof PermissionResolvedMessage) {
permissionTerminalById.set(message.permissionRequestId, {
state: 'resolved',
allowed: message.allowed,
})
} else if (message instanceof PermissionCancelledMessage) {
permissionTerminalById.set(message.permissionRequestId, {
state: 'cancelled',
reason: message.reason,
})
}

if (shouldSkipStandaloneMessage(message)) {
index += 1;
continue;
Expand All @@ -60,6 +88,7 @@ export function buildConversationFeedRenderItems(
while (index < messages.length) {
const candidate = messages[index];
if (candidate instanceof ToolResultMessage) {
toolResultIndex.set(candidate.toolId, candidate);
index += 1;
continue;
}
Expand Down Expand Up @@ -100,5 +129,11 @@ export function buildConversationFeedRenderItems(
index += 1;
}

return items;
return { items, toolResultIndex, permissionTerminalById };
}

export function buildConversationFeedRenderItems(
messages: ChatMessage[],
): ConversationFeedRenderItem[] {
return buildConversationFeedRenderModel(messages).items;
}
47 changes: 7 additions & 40 deletions web/src/lib/components/chat/ConversationFeed.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@
import MessageRenderFallback from './MessageRenderFallback.svelte';
import {
isToolUseMessage,
ToolResultMessage,
PermissionRequestMessage,
PermissionResolvedMessage,
PermissionCancelledMessage,
} from '$shared/chat-types';
import type { PendingPermissionRequest } from '$lib/types/chat';
import { getChatState, getAgentState, getLocalSettings, getAppShell } from '$lib/context';
import * as m from '$lib/paraglide/messages.js';
import { createMessageIdAllocator } from '$lib/chat/message-id';
import { buildConversationFeedRenderItems } from '$lib/chat/conversation-feed-items';
import { buildConversationFeedRenderModel } from '$lib/chat/conversation-feed-items';
import {
CHAT_MAX_WIDTH_FEED_CONTENT_CLASS,
CHAT_MAX_WIDTH_FEED_VIEWPORT_CLASS,
Expand Down Expand Up @@ -69,39 +66,6 @@
}
});

// Builds a lookup of toolId -> ToolResultMessage so tool-use messages
// can render their results inline. tool-result messages are then
// skipped from the main render loop.
const toolResultIndex = $derived.by(() => {
const index = new Map<string, ToolResultMessage>();
for (const msg of chatState.visibleMessages) {
if (msg instanceof ToolResultMessage) {
index.set(msg.toolId, msg);
}
}
return index;
});

// Builds a lookup from permissionRequestId to terminal state so
// permission-request rows render as pending/resolved/cancelled.
// Terminal messages (permission-resolved, permission-cancelled) are
// skipped from the main render loop.
const permissionTerminalById = $derived.by(() => {
const map = new Map<
string,
{ state: 'resolved' | 'cancelled'; allowed?: boolean; reason?: string }
>();
for (const m of chatState.visibleMessages) {
if (m instanceof PermissionResolvedMessage) {
map.set(m.permissionRequestId, { state: 'resolved', allowed: m.allowed });
}
if (m instanceof PermissionCancelledMessage) {
map.set(m.permissionRequestId, { state: 'cancelled', reason: m.reason });
}
}
return map;
});

// Tracks which ExitPlanMode synthetic permission requests are still
// pending so we can derive terminal state for the tool-use rendering.
// Matches both PascalCase and snake_case variants since claude-cli
Expand All @@ -114,7 +78,10 @@
),
);

const renderItems = $derived(buildConversationFeedRenderItems(chatState.visibleMessages));
// Builds render items and lookup tables in a single pass over the
// visible transcript, keeping stream updates cheaper.
const renderModel = $derived(buildConversationFeedRenderModel(chatState.visibleMessages));
const renderItems = $derived(renderModel.items);

const feedScrollAreaClass = 'h-full overflow-hidden relative';
const feedViewportClass = $derived(
Expand Down Expand Up @@ -239,13 +206,13 @@
{:else}
{@const message = item.message}
{@const toolResult = isToolUseMessage(message)
? toolResultIndex.get(message.toolId)
? renderModel.toolResultIndex.get(message.toolId)
: undefined}
{@const exitPlanId =
message.type === 'exit-plan-mode-tool-use' ? `plan-exit-${message.toolId}` : null}
{@const permTerminal =
message instanceof PermissionRequestMessage
? permissionTerminalById.get(message.permissionRequestId)
? renderModel.permissionTerminalById.get(message.permissionRequestId)
: exitPlanId
? pendingExitPlanIds.has(exitPlanId)
? undefined
Expand Down
32 changes: 22 additions & 10 deletions web/src/lib/components/chat/ConversationMessage.svelte
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
<script module lang="ts">
type ChatToolEventRendererModule = typeof import('./tools/ChatToolEventRenderer.svelte');

let chatToolEventRendererPromise: Promise<ChatToolEventRendererModule> | null = null;

function loadChatToolEventRenderer(): Promise<ChatToolEventRendererModule> {
chatToolEventRendererPromise ??= import('./tools/ChatToolEventRenderer.svelte');
return chatToolEventRendererPromise;
}
</script>

<script lang="ts">
import {
UserMessage,
Expand All @@ -17,7 +28,6 @@
import Markdown from './Markdown.svelte';
import type { MarkdownLinkNavigateEvent } from './Markdown.svelte';
import { parseFileLink } from '$lib/chat/file-link-parser';
import ChatToolEventRenderer from './tools/ChatToolEventRenderer.svelte';
import PermissionRequestRow from './PermissionRequestRow.svelte';
import ChatEventCard from './rows/ChatEventCard.svelte';
import {
Expand Down Expand Up @@ -317,15 +327,17 @@
{onExitPlanMode}
/>
{:else if asToolUse}
<ChatToolEventRenderer
toolMessage={asToolUse}
toolResult={toolResult
? { content: toolResult.content, isError: toolResult.isError }
: undefined}
mode="input"
autoExpandTools={localSettings.autoExpandTools}
onFileOpen={handleToolFileOpen}
/>
{#await loadChatToolEventRenderer() then { default: ChatToolEventRenderer }}
<ChatToolEventRenderer
toolMessage={asToolUse}
toolResult={toolResult
? { content: toolResult.content, isError: toolResult.isError }
: undefined}
mode="input"
autoExpandTools={localSettings.autoExpandTools}
onFileOpen={handleToolFileOpen}
/>
{/await}
{:else if asThinking}
<ChatEventCard variant="thinking" compact>
{#snippet body()}
Expand Down
76 changes: 54 additions & 22 deletions web/src/lib/components/files/FileViewerHost.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
<script module lang="ts">
type FileEditorDialogModule = typeof import('./FileEditorDialog.svelte');
type ImageViewerModule = typeof import('./ImageViewer.svelte');
type MarkdownViewerModule = typeof import('./MarkdownViewer.svelte');

let fileEditorDialogPromise: Promise<FileEditorDialogModule> | null = null;
let imageViewerPromise: Promise<ImageViewerModule> | null = null;
let markdownViewerPromise: Promise<MarkdownViewerModule> | null = null;

function loadFileEditorDialog(): Promise<FileEditorDialogModule> {
fileEditorDialogPromise ??= import('./FileEditorDialog.svelte');
return fileEditorDialogPromise;
}

function loadImageViewer(): Promise<ImageViewerModule> {
imageViewerPromise ??= import('./ImageViewer.svelte');
return imageViewerPromise;
}

function loadMarkdownViewer(): Promise<MarkdownViewerModule> {
markdownViewerPromise ??= import('./MarkdownViewer.svelte');
return markdownViewerPromise;
}
</script>

<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { getFileViewer } from '$lib/context';
import { FileViewerHostState } from './file-viewer-host-state.svelte';
import FileEditorDialog from './FileEditorDialog.svelte';
import ImageViewer from './ImageViewer.svelte';
import MarkdownViewer from './MarkdownViewer.svelte';

const viewer = getFileViewer();
const host = new FileViewerHostState({
Expand All @@ -27,27 +49,37 @@

{#if host.session && host.file}
{#if host.session.mode === 'image'}
<ImageViewer src={host.getImageUrl()} alt={host.file.name} onClose={() => host.closeViewer()} />
{#await loadImageViewer() then { default: ImageViewer }}
<ImageViewer
src={host.getImageUrl()}
alt={host.file.name}
onClose={() => host.closeViewer()}
/>
{/await}
{:else if host.session.mode === 'markdown'}
<MarkdownViewer
filePath={host.file.path}
content={host.file.content}
onClose={() => host.closeViewer()}
onEdit={() => host.switchToCodeView()}
/>
{#await loadMarkdownViewer() then { default: MarkdownViewer }}
<MarkdownViewer
filePath={host.file.path}
content={host.file.content}
onClose={() => host.closeViewer()}
onEdit={() => host.switchToCodeView()}
/>
{/await}
{:else}
<FileEditorDialog
file={host.toEditorFile()}
onRequestClose={() => host.closeViewer()}
onSave={(content) => {
host.setEditorContent(content);
return host.saveCurrentFile();
}}
onContentChange={(value) => host.setEditorContent(value)}
onDirtyChange={(dirty) => host.setDirty(dirty)}
showMarkdownViewButton={host.isCurrentFileMarkdownInCodeMode}
onRequestMarkdownView={() => host.switchToMarkdownView()}
/>
{#await loadFileEditorDialog() then { default: FileEditorDialog }}
<FileEditorDialog
file={host.toEditorFile()}
onRequestClose={() => host.closeViewer()}
onSave={(content) => {
host.setEditorContent(content);
return host.saveCurrentFile();
}}
onContentChange={(value) => host.setEditorContent(value)}
onDirtyChange={(dirty) => host.setDirty(dirty)}
showMarkdownViewButton={host.isCurrentFileMarkdownInCodeMode}
onRequestMarkdownView={() => host.switchToMarkdownView()}
/>
{/await}
{/if}
{/if}

Expand Down
Loading