refactor: Clean up UI types (#26909)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user