refactor: Clean up UI types (#26909)

This commit is contained in:
Aleksander Grygier
2026-08-13 08:07:34 +02:00
committed by GitHub
parent 1f368f354d
commit a6040c925c
53 changed files with 384 additions and 290 deletions
+1 -52
View File
@@ -13,6 +13,7 @@ import {
MessageRole,
ToolResultKind
} from '$lib/enums';
import type { AgenticSection, ContinueIntent, ToolResultLine } from '$lib/types/agentic';
import type { ApiChatCompletionToolCall } from '$lib/types/api';
import type {
DatabaseMessage,
@@ -20,35 +21,6 @@ import type {
DatabaseMessageExtraImageFile
} from '$lib/types/database';
/**
* Represents a parsed section of agentic content for display
*/
export interface AgenticSection {
type: AgenticSectionType;
content: string;
toolName?: string;
toolArgs?: string;
toolResult?: string;
toolResultExtras?: DatabaseMessageExtra[];
/** Working directory the tool call ran with (from the tool result
* message), shown by the exec_shell_command renderer. */
toolCwd?: string;
/** ID of the model-side tool call (matches tool_calls[i].id). Lets
* downstream consumers correlate a section with the agentic loop's
* currently-executing tool, e.g. to drive live-streaming UI state
* by matching against agenticStore.executingToolCallId. */
toolCallId?: string;
wasInterrupted?: boolean;
}
/**
* Represents a tool result line that may reference a media attachment (image or audio)
*/
export type ToolResultLine = {
text: string;
media?: DatabaseMessageExtraImageFile | DatabaseMessageExtraAudioFile;
};
/**
* Derives display sections from a single assistant message and its direct tool results.
*
@@ -485,29 +457,6 @@ export function hasAgenticContent(
return toolMessages.length > 0;
}
/**
* Classification of how a Continue click on an assistant message should resume
* generation. The caller dispatches the resume path based on this value.
*
* append_text -> the target is a plain text turn, resume with
* continue_final_message and rehydrate the persisted
* tool_calls and attachments through the regular DB to API
* message converter.
* rerun_turn -> the target carries tool_calls that were never resolved by
* tool result messages. The agentic stream was cut mid turn,
* so we drop the target and rerun the loop from the previous
* history. truncateAfter is the last kept index, inclusive.
* next_turn -> the target's tool_calls were already resolved by trailing
* tool results. Hand the history up to and including the
* last consecutive tool result back to the agentic loop so it
* starts the next turn naturally. truncateAfter points at
* that last tool result.
*/
export type ContinueIntent =
| { kind: ContinueIntentKind.APPEND_TEXT }
| { kind: ContinueIntentKind.RERUN_TURN; truncateAfter: number }
| { kind: ContinueIntentKind.NEXT_TURN; truncateAfter: number };
/**
* Decide how a Continue click on messages[idx] should resume generation.
* Pure function over the persisted history snapshot.
@@ -35,14 +35,10 @@ import {
MENTION_BADGE_SVG_ATTRIBUTES,
SETTINGS_KEYS
} from '$lib/constants';
import { ContentEditableTokenKind } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toolsStore } from '$lib/stores/tools.svelte';
export type ContentToken =
| { kind: 'text'; text: string }
| { kind: 'badge'; name: string; path: string }
| { kind: 'inlineCode'; text: string }
| { kind: 'codeBlock'; text: string };
import type { ContentEditableToken } from '$lib/types/contenteditable';
// Block wrappers browsers insert for newlines; each folds back into a
// single `\n` during serialization.
@@ -112,8 +108,8 @@ export function isOffsetInCodeBlock(source: string, offset: number): boolean {
* interleave in the remaining gaps. Any whitespace after a badge
* stays in a plain text token so the round trip is byte-exact.
*/
export function tokenizeContent(input: string): ContentToken[] {
const tokens: ContentToken[] = [];
export function tokenizeContent(input: string): ContentEditableToken[] {
const tokens: ContentEditableToken[] = [];
let cursor = 0;
@@ -130,8 +126,8 @@ export function tokenizeContent(input: string): ContentToken[] {
tokens.push(
match[1] !== undefined
? { kind: 'codeBlock', text: match[1] }
: { kind: 'inlineCode', text: match[2] }
? { kind: ContentEditableTokenKind.CODE_BLOCK, text: match[1] }
: { kind: ContentEditableTokenKind.INLINE_CODE, text: match[2] }
);
cursor = start + match[0].length;
}
@@ -146,7 +142,7 @@ export function tokenizeContent(input: string): ContentToken[] {
/**
* Tokenize a code-free segment into text and badge tokens.
*/
function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
function pushTextAndBadgeTokens(input: string, tokens: ContentEditableToken[]) {
let cursor = 0;
MENTION_BADGE_RE.lastIndex = 0;
@@ -158,15 +154,15 @@ function pushTextAndBadgeTokens(input: string, tokens: ContentToken[]) {
const start = match.index;
if (start > cursor) {
tokens.push({ kind: 'text', text: input.slice(cursor, start) });
tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor, start) });
}
tokens.push({ kind: 'badge', name, path });
tokens.push({ kind: ContentEditableTokenKind.BADGE, name, path });
cursor = start + whole.length;
}
if (cursor < input.length) {
tokens.push({ kind: 'text', text: input.slice(cursor) });
tokens.push({ kind: ContentEditableTokenKind.TEXT, text: input.slice(cursor) });
}
}
@@ -289,8 +285,8 @@ export function serializeContent(root: HTMLElement): string {
* A mismatch means token boundaries shifted (a code span was just
* completed or broken) and the DOM needs a rebuild to restyle.
*/
export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boolean {
const expected = tokens.filter((token) => token.kind !== 'text');
export function domMatchesTokens(root: HTMLElement, tokens: ContentEditableToken[]): boolean {
const expected = tokens.filter((token) => token.kind !== ContentEditableTokenKind.TEXT);
let index = 0;
@@ -313,7 +309,7 @@ export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boo
if (!token) return false;
if (isBadge) {
if (token.kind !== 'badge') return false;
if (token.kind !== ContentEditableTokenKind.BADGE) return false;
if (token.name !== (el.dataset.mentionName ?? '')) return false;
@@ -322,15 +318,18 @@ export function domMatchesTokens(root: HTMLElement, tokens: ContentToken[]): boo
continue;
}
const codeKind = el.dataset.codeToken === 'block' ? 'codeBlock' : 'inlineCode';
const codeKind: ContentEditableTokenKind =
el.dataset.codeToken === 'block'
? ContentEditableTokenKind.CODE_BLOCK
: ContentEditableTokenKind.INLINE_CODE;
if (token.kind !== codeKind) return false;
if (
(token.kind === 'inlineCode' || token.kind === 'codeBlock') &&
token.text !== (el.textContent ?? '')
token.kind === ContentEditableTokenKind.INLINE_CODE ||
token.kind === ContentEditableTokenKind.CODE_BLOCK
) {
return false;
if (token.text !== (el.textContent ?? '')) return false;
}
}
@@ -522,23 +521,26 @@ export function rangeToTextOffset(root: HTMLElement, range: Range | null): numbe
* string + inline SVG are shared with the rehype plugin via
* `$lib/constants`.
*/
export function buildFragment(tokens: ContentToken[]): DocumentFragment {
export function buildFragment(tokens: ContentEditableToken[]): DocumentFragment {
const fragment = document.createDocumentFragment();
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (token.kind === 'text') {
if (token.kind === ContentEditableTokenKind.TEXT) {
let text = token.text;
// The separator \n at a fenced-block boundary is synthesized
// at serialization time; keeping it in the DOM would render a
// phantom empty line next to the block.
if (tokens[index - 1]?.kind === 'codeBlock' && text.startsWith('\n')) {
if (
tokens[index - 1]?.kind === ContentEditableTokenKind.CODE_BLOCK &&
text.startsWith('\n')
) {
text = text.slice(1);
}
if (tokens[index + 1]?.kind === 'codeBlock' && text.endsWith('\n')) {
if (tokens[index + 1]?.kind === ContentEditableTokenKind.CODE_BLOCK && text.endsWith('\n')) {
text = text.slice(0, -1);
}
@@ -549,10 +551,14 @@ export function buildFragment(tokens: ContentToken[]): DocumentFragment {
continue;
}
if (token.kind === 'inlineCode' || token.kind === 'codeBlock') {
if (
token.kind === ContentEditableTokenKind.INLINE_CODE ||
token.kind === ContentEditableTokenKind.CODE_BLOCK
) {
const code = document.createElement('code');
code.dataset.codeToken = token.kind === 'codeBlock' ? 'block' : 'inline';
code.dataset.codeToken =
token.kind === ContentEditableTokenKind.CODE_BLOCK ? 'block' : 'inline';
code.textContent = token.text;
fragment.appendChild(code);
@@ -728,11 +734,14 @@ export function badgeAwareWordJump(
for (const token of tokenizeContent(source)) {
const len =
token.kind === 'badge' ? badgeSourceLength(token.name, token.path) : token.text.length;
token.kind === ContentEditableTokenKind.BADGE
? badgeSourceLength(token.name, token.path)
: token.text.length;
if (token.kind === 'badge') badgeSpans.push([masked.length, masked.length + len]);
if (token.kind === ContentEditableTokenKind.BADGE)
badgeSpans.push([masked.length, masked.length + len]);
masked += token.kind === 'badge' ? 'a'.repeat(len) : token.text;
masked += token.kind === ContentEditableTokenKind.BADGE ? 'a'.repeat(len) : token.text;
}
if (badgeSpans.length === 0) return null;
@@ -795,7 +804,7 @@ export function badgeAwareWordJump(
export function leadingBadgeEdgeOffset(source: string, caret: number): number | null {
const [first] = tokenizeContent(source);
if (!first || first.kind !== 'badge') return null;
if (!first || first.kind !== ContentEditableTokenKind.BADGE) return null;
return caret === badgeSourceLength(first.name, first.path) ? 0 : null;
}
+9 -37
View File
@@ -5,16 +5,18 @@
*/
import { lastPathSegment } from './path-display';
import {
buildGlobSearchArgs,
type GlobEntry,
type GlobSearchArgs,
joinPath,
rankEntries
} from './working-directory';
import { buildGlobSearchArgs, joinPath, rankEntries } from './working-directory';
import { GLOB, PATH_SEPARATOR, SEARCH } from '$lib/constants';
import { BuiltInTool, GlobSearchType } from '$lib/enums';
import { ToolsService } from '$lib/services/tools.service';
import type {
GlobEntry,
GlobEntryResult,
GlobSearchArgs,
GlobSearchChildOptions,
GlobSearchChildResult,
GlobSearchResult
} from '$lib/types/glob';
const SEARCH_CACHE_TTL_MS = 2000;
@@ -26,12 +28,6 @@ interface CacheEntry {
const searchCache = new Map<string, CacheEntry>();
export interface GlobSearchResult {
base: string;
entries: GlobEntry[];
error?: string;
}
export async function runGlobSearch(
args: GlobSearchArgs,
type: GlobSearchType,
@@ -66,30 +62,6 @@ export async function runGlobSearch(
return { base, entries };
}
export interface GlobEntryResult {
path: string;
name: string;
type: string;
}
export interface GlobSearchChildOptions {
type?: GlobSearchType;
/** Descend only on a trailing path separator (mention picker); off for
* the WD picker, which descends on any exact match. */
descendOnTrailingSeparator?: boolean;
childMaxDepth?: number;
}
export interface GlobSearchChildResult {
base: string;
args: GlobSearchArgs;
/** Outer ranked entries plus the walked directory's children (absolute). */
entries: GlobEntryResult[];
/** Absolute path of the directory whose children were appended. */
exactDir?: string;
error?: string;
}
function toEntryResult(e: GlobEntry, base: string): GlobEntryResult {
return { name: lastPathSegment(e.path), path: joinPath(base, e.path), type: e.type };
}
+4 -16
View File
@@ -179,18 +179,11 @@ export {
rankEntries,
joinPath,
highlightMatch,
type GlobEntry,
type GlobSearchArgs,
type PathQuery
} from './working-directory';
// Shared `file_glob_search` runner with a short-lived result cache
export {
runGlobSearch,
runGlobSearchWithChildren,
type GlobEntryResult,
type GlobSearchResult
} from './glob-search';
export { runGlobSearch, runGlobSearchWithChildren } from './glob-search';
// Mention-token detection (for the `@`-triggered file/folder mention picker)
export {
@@ -219,8 +212,7 @@ export {
rangeToTextOffset,
textOffsetToRange,
badgeAwareWordJump,
leadingBadgeEdgeOffset,
type ContentToken
leadingBadgeEdgeOffset
} from './contenteditable-tokenizer';
// Source-space undo/redo history for the chat-form contenteditable
@@ -251,9 +243,7 @@ export {
parseToolResultWithMedia,
splitSearchSummaryList,
hasAgenticContent,
classifyToolResult,
type AgenticSection,
type ToolResultLine
classifyToolResult
} from './agentic';
// Line-level unified diff for tool result rendering (`edit_file` block)
@@ -276,8 +266,7 @@ export {
extractSearchResults,
extractSearchQuery,
faviconForUrl,
isWebSearchToolName,
type SearchResult
isWebSearchToolName
} from './search-results';
// Cache utilities
@@ -318,7 +307,6 @@ export { tryParseToolResultObject } from './tool-call-meta';
// Re-exported through $lib/utils so renderer components can read the
// label without depending on $lib/constants directly.
export { getBuiltinToolUi } from './built-in-tools';
export type { BuiltinToolUiEntry } from '$lib/types';
// Chat command picker
+2 -8
View File
@@ -1,3 +1,5 @@
import type { SearchResult } from '$lib/types/search';
/**
* Parsers for MCP web-search tool responses shaped like:
*
@@ -16,14 +18,6 @@
* servers without hardcoding tool names.
*/
export type SearchResult = {
title: string;
url: string;
published?: string;
author?: string;
highlights?: string;
};
const SEPARATOR_LINE_RE = /^\s*---\s*$/;
const URL_SCHEME_RE = /^https?:\/\//i;
// Match either Unix or Windows line endings so chunking/parsing handles
+1 -16
View File
@@ -14,11 +14,7 @@ import {
SEARCH,
TRAILING_SLASHES_REGEX
} from '$lib/constants';
export interface GlobEntry {
path: string;
type: string;
}
import type { GlobEntry, GlobSearchArgs } from '$lib/types/glob';
export interface PathQuery {
parent: string;
@@ -93,17 +89,6 @@ export function buildCaseInsensitiveGlob(query: string): string {
return out + GLOB.WILDCARD;
}
export interface GlobSearchArgs {
path: string;
include: string;
maxDepth: number;
rankQuery: string;
/** Last segment of a path-navigation query (`~/dir/sub`), undefined for
* a plain home-relative glob. Lets callers act on the exact targeted
* segment (e.g. the WD picker "entering" a directory). */
last?: string;
}
export function buildGlobSearchArgs(
query: string,
scopePath: string,