hangwin--mcp-chrome
734 行
21 KiB
TypeScript
734 行
21 KiB
TypeScript
/**
|
|
* Composable for grouping messages into conversation threads.
|
|
* Transforms flat AgentMessage[] into structured AgentThread[] for UI rendering.
|
|
*/
|
|
import { computed, type InjectionKey, type Ref } from 'vue';
|
|
import type {
|
|
AgentMessage,
|
|
AgentMessageAttachmentMetadata,
|
|
AttachmentMetadata,
|
|
} from 'chrome-mcp-shared';
|
|
import type { RequestState } from './useAgentChat';
|
|
|
|
/**
|
|
* Injection key for agent server port.
|
|
* Provided by AgentChat.vue for child components to access attachment URLs.
|
|
*/
|
|
export const AGENT_SERVER_PORT_KEY: InjectionKey<Ref<number | null>> = Symbol('agentServerPort');
|
|
|
|
/** Thread state */
|
|
export type AgentThreadState =
|
|
| 'idle'
|
|
| 'starting'
|
|
| 'running'
|
|
| 'completed'
|
|
| 'error'
|
|
| 'cancelled';
|
|
|
|
/** Tool kinds for presentation */
|
|
export type ToolKind = 'grep' | 'read' | 'edit' | 'run' | 'plan' | 'generic';
|
|
|
|
/** Tool severity for styling */
|
|
export type ToolSeverity = 'info' | 'success' | 'warning' | 'error';
|
|
|
|
/** Diff statistics for edit operations */
|
|
export interface DiffStats {
|
|
addedLines?: number;
|
|
deletedLines?: number;
|
|
totalLines?: number;
|
|
}
|
|
|
|
/** Structured tool presentation */
|
|
export interface ToolPresentation {
|
|
kind: ToolKind;
|
|
label: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
details?: string;
|
|
files?: string[];
|
|
/** File path for single-file operations */
|
|
filePath?: string;
|
|
/** Diff statistics for edit/write operations */
|
|
diffStats?: DiffStats;
|
|
command?: string;
|
|
/** Command description from bash tool */
|
|
commandDescription?: string;
|
|
query?: string;
|
|
/** Search pattern for grep/glob */
|
|
pattern?: string;
|
|
/** Search path */
|
|
searchPath?: string;
|
|
engine?: string;
|
|
severity: ToolSeverity;
|
|
phase: 'use' | 'result';
|
|
raw: { content: string; metadata?: Record<string, unknown> };
|
|
}
|
|
|
|
/** Timeline item types */
|
|
export type TimelineItem =
|
|
| {
|
|
kind: 'user_prompt';
|
|
id: string;
|
|
requestId?: string;
|
|
createdAt: string;
|
|
messageId: string;
|
|
text: string;
|
|
attachments: AttachmentMetadata[];
|
|
}
|
|
| {
|
|
kind: 'assistant_text';
|
|
id: string;
|
|
requestId?: string;
|
|
createdAt: string;
|
|
messageId: string;
|
|
text: string;
|
|
isStreaming: boolean;
|
|
}
|
|
| {
|
|
kind: 'tool_use';
|
|
id: string;
|
|
requestId?: string;
|
|
createdAt: string;
|
|
messageId: string;
|
|
tool: ToolPresentation;
|
|
isStreaming: boolean;
|
|
}
|
|
| {
|
|
kind: 'tool_result';
|
|
id: string;
|
|
requestId?: string;
|
|
createdAt: string;
|
|
messageId: string;
|
|
tool: ToolPresentation;
|
|
isError: boolean;
|
|
}
|
|
| {
|
|
kind: 'status';
|
|
id: string;
|
|
requestId?: string;
|
|
createdAt: string;
|
|
status: string;
|
|
text?: string;
|
|
};
|
|
|
|
/** Client metadata for web editor apply messages */
|
|
export interface WebEditorApplyMeta {
|
|
kind: 'web_editor_apply_batch' | 'web_editor_apply_single';
|
|
pageUrl?: string;
|
|
elementCount?: number;
|
|
elementLabels?: string[];
|
|
}
|
|
|
|
/** Thread header data for special message types */
|
|
export interface ThreadHeader {
|
|
/** Display text (compact representation) */
|
|
displayText?: string;
|
|
/** Full prompt content for hover display */
|
|
fullContent: string;
|
|
/** Web editor apply metadata */
|
|
webEditorApply?: WebEditorApplyMeta;
|
|
}
|
|
|
|
/** A grouped conversation thread */
|
|
export interface AgentThread {
|
|
id: string;
|
|
requestId?: string;
|
|
title: string;
|
|
createdAt: string;
|
|
state: AgentThreadState;
|
|
items: TimelineItem[];
|
|
/** Attachments from the user prompt (for display in thread header) */
|
|
attachments: AttachmentMetadata[];
|
|
/** Thread header data for special message rendering */
|
|
header?: ThreadHeader;
|
|
}
|
|
|
|
/** Options for useAgentThreads */
|
|
export interface UseAgentThreadsOptions {
|
|
messages: Ref<AgentMessage[]>;
|
|
/** Request lifecycle state (replaces isStreaming for thread state calculation) */
|
|
requestState: Ref<RequestState>;
|
|
currentRequestId: Ref<string | null>;
|
|
}
|
|
|
|
/**
|
|
* Normalize a string for comparison
|
|
*/
|
|
function normalize(s: string | undefined): string {
|
|
return (s ?? '').toLowerCase().trim();
|
|
}
|
|
|
|
/**
|
|
* Get first string from multiple candidates
|
|
*/
|
|
function firstString(...args: unknown[]): string | undefined {
|
|
for (const arg of args) {
|
|
if (typeof arg === 'string' && arg.trim()) {
|
|
return arg.trim();
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Extract text after a prefix (e.g., "Running: <command>")
|
|
*/
|
|
function extractAfterPrefix(content: string, prefix: string): string | undefined {
|
|
const idx = content.indexOf(prefix);
|
|
if (idx === -1) return undefined;
|
|
return content.slice(idx + prefix.length).trim();
|
|
}
|
|
|
|
/**
|
|
* Summarize content to one line
|
|
*/
|
|
function summarizeOneLine(content: string): string {
|
|
const line = content.split('\n')[0]?.trim() ?? '';
|
|
return line.length > 60 ? line.slice(0, 57) + '...' : line;
|
|
}
|
|
|
|
/**
|
|
* Title case a string
|
|
*/
|
|
function titleCase(s: string): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Extract file name from path
|
|
*/
|
|
function getFileName(filePath: string): string {
|
|
return filePath.split('/').pop() || filePath;
|
|
}
|
|
|
|
/**
|
|
* Build diff stats from metadata
|
|
*/
|
|
function buildDiffStats(meta: Record<string, unknown>): DiffStats | undefined {
|
|
const addedLines = typeof meta.addedLines === 'number' ? meta.addedLines : undefined;
|
|
const deletedLines = typeof meta.deletedLines === 'number' ? meta.deletedLines : undefined;
|
|
const totalLines = typeof meta.totalLines === 'number' ? meta.totalLines : undefined;
|
|
|
|
if (addedLines !== undefined || deletedLines !== undefined || totalLines !== undefined) {
|
|
return { addedLines, deletedLines, totalLines };
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Present a tool message as ToolPresentation
|
|
*/
|
|
function presentTool(msg: AgentMessage): ToolPresentation {
|
|
const meta = (msg.metadata ?? {}) as Record<string, unknown>;
|
|
const phase = msg.messageType === 'tool_use' ? 'use' : 'result';
|
|
const engine = msg.cliSource;
|
|
|
|
const toolName =
|
|
firstString(meta.toolName as string, meta.tool_name as string) ??
|
|
(typeof engine === 'string' ? engine : undefined) ??
|
|
'tool';
|
|
|
|
const isError =
|
|
meta.is_error === true ||
|
|
meta.isError === true ||
|
|
(typeof msg.content === 'string' && msg.content.trimStart().startsWith('Error:'));
|
|
|
|
// Extract common metadata fields
|
|
const filePath = firstString(meta.filePath as string);
|
|
const command = firstString(meta.command as string);
|
|
const commandDescription = firstString(meta.commandDescription as string);
|
|
const pattern = firstString(meta.pattern as string);
|
|
const searchPath = firstString(meta.searchPath as string);
|
|
const diffStats = buildDiffStats(meta);
|
|
|
|
// Rule 1: Plan / TodoWrite
|
|
if (
|
|
meta.planPhase ||
|
|
normalize(toolName) === 'plan' ||
|
|
normalize(toolName) === 'todo_write' ||
|
|
normalize(toolName) === 'todowrite'
|
|
) {
|
|
const todoCount = typeof meta.todoCount === 'number' ? meta.todoCount : undefined;
|
|
return {
|
|
kind: 'plan',
|
|
label: 'Plan',
|
|
title: todoCount ? `${todoCount} tasks` : summarizeOneLine(msg.content) || 'Plan update',
|
|
details: phase === 'result' ? msg.content : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : 'info',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 2: Edit tool with file path and diff stats
|
|
if (
|
|
normalize(toolName).includes('edit') ||
|
|
normalize(toolName) === 'apply_patch' ||
|
|
normalize(toolName) === 'patch_file'
|
|
) {
|
|
const fileName = filePath ? getFileName(filePath) : undefined;
|
|
return {
|
|
kind: 'edit',
|
|
label: 'Edit',
|
|
title: fileName || filePath || 'File',
|
|
filePath,
|
|
diffStats,
|
|
details: phase === 'result' ? msg.content : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : 'success',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 3: Write/Create tool
|
|
if (normalize(toolName).includes('write') || normalize(toolName) === 'create_file') {
|
|
const fileName = filePath ? getFileName(filePath) : undefined;
|
|
return {
|
|
kind: 'edit',
|
|
label: 'Write',
|
|
title: fileName || filePath || 'File',
|
|
filePath,
|
|
diffStats,
|
|
details: phase === 'result' ? msg.content : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : 'success',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 4: File summary (Codex file_change -> metadata.files)
|
|
const files = Array.isArray(meta.files)
|
|
? (meta.files as string[]).filter((x) => typeof x === 'string')
|
|
: [];
|
|
if (files.length > 0) {
|
|
const title = files.length === 1 ? getFileName(files[0]) : `${files.length} files`;
|
|
return {
|
|
kind: 'edit',
|
|
label: 'Edit',
|
|
title,
|
|
subtitle: files.length > 1 ? files.slice(0, 3).map(getFileName).join(', ') : undefined,
|
|
files,
|
|
filePath: files.length === 1 ? files[0] : undefined,
|
|
diffStats,
|
|
details: phase === 'result' ? msg.content : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : 'success',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 5: Command (Bash/shell)
|
|
if (
|
|
normalize(toolName) === 'bash' ||
|
|
normalize(toolName).includes('shell') ||
|
|
typeof command === 'string' ||
|
|
msg.content.startsWith('Running:') ||
|
|
msg.content.startsWith('Ran:')
|
|
) {
|
|
const extractedCommand =
|
|
command ??
|
|
extractAfterPrefix(msg.content, 'Running:') ??
|
|
extractAfterPrefix(msg.content, 'Ran:') ??
|
|
undefined;
|
|
|
|
const details =
|
|
firstString(meta.output as string) ?? (phase === 'result' ? msg.content : undefined);
|
|
|
|
return {
|
|
kind: 'run',
|
|
label: 'Run',
|
|
title: commandDescription || extractedCommand?.trim() || 'Command',
|
|
subtitle: commandDescription && extractedCommand ? extractedCommand.trim() : undefined,
|
|
command: extractedCommand?.trim(),
|
|
commandDescription,
|
|
details,
|
|
engine,
|
|
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 6: Grep/Search with pattern
|
|
if (normalize(toolName) === 'grep' || normalize(toolName).includes('search') || pattern) {
|
|
const queryFromContent = extractAfterPrefix(msg.content, 'Searching:');
|
|
const displayPattern = pattern || queryFromContent?.trim();
|
|
return {
|
|
kind: 'grep',
|
|
label: 'Grep',
|
|
title: displayPattern || 'Search',
|
|
pattern: displayPattern,
|
|
searchPath,
|
|
query: displayPattern,
|
|
details: phase === 'result' ? msg.content : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : 'info',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 7: Glob with pattern
|
|
if (normalize(toolName) === 'glob' || normalize(toolName) === 'glob_files') {
|
|
return {
|
|
kind: 'grep',
|
|
label: 'Glob',
|
|
title: pattern || 'Pattern search',
|
|
pattern,
|
|
searchPath,
|
|
details: phase === 'result' ? msg.content : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : 'info',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 8: Read tool
|
|
if (normalize(toolName).includes('read') || filePath) {
|
|
const fileName = filePath ? getFileName(filePath) : undefined;
|
|
return {
|
|
kind: 'read',
|
|
label: 'Read',
|
|
title: fileName || filePath || 'File',
|
|
filePath,
|
|
engine,
|
|
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Rule 9: Read / Edit by action (fallback for content-based detection)
|
|
const action = firstString(meta.action as string);
|
|
const fileFromContent = extractAfterPrefix(msg.content, 'Operating on:')?.trim();
|
|
const inferredKind =
|
|
action === 'Read'
|
|
? 'read'
|
|
: action === 'Edited' || action === 'Created' || action === 'Deleted'
|
|
? 'edit'
|
|
: null;
|
|
|
|
if (fileFromContent || inferredKind) {
|
|
const kind: ToolKind = inferredKind ?? 'read';
|
|
return {
|
|
kind,
|
|
label: kind === 'read' ? 'Read' : 'Edit',
|
|
title: fileFromContent ? getFileName(fileFromContent) : toolName,
|
|
filePath: fileFromContent,
|
|
diffStats: kind === 'edit' ? diffStats : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
// Fallback: generic tool
|
|
return {
|
|
kind: 'generic',
|
|
label: titleCase(toolName),
|
|
title: summarizeOneLine(msg.content) || `Using ${toolName}`,
|
|
details: phase === 'result' ? msg.content : undefined,
|
|
engine,
|
|
severity: isError ? 'error' : 'info',
|
|
phase,
|
|
raw: { content: msg.content, metadata: meta },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Type guard for AttachmentMetadata.
|
|
* Validates that an unknown value conforms to the AttachmentMetadata interface.
|
|
* Includes semantic validation (non-empty strings, valid numbers).
|
|
*/
|
|
function isAttachmentMetadata(value: unknown): value is AttachmentMetadata {
|
|
if (!value || typeof value !== 'object') return false;
|
|
const v = value as Record<string, unknown>;
|
|
const index = v.index;
|
|
const sizeBytes = v.sizeBytes;
|
|
return (
|
|
v.version === 1 &&
|
|
v.kind === 'image' &&
|
|
typeof v.projectId === 'string' &&
|
|
(v.projectId as string).trim().length > 0 &&
|
|
typeof v.messageId === 'string' &&
|
|
(v.messageId as string).trim().length > 0 &&
|
|
typeof index === 'number' &&
|
|
Number.isInteger(index) &&
|
|
index >= 0 &&
|
|
typeof v.filename === 'string' &&
|
|
(v.filename as string).trim().length > 0 &&
|
|
typeof v.urlPath === 'string' &&
|
|
(v.urlPath as string).trim().length > 0 &&
|
|
typeof v.mimeType === 'string' &&
|
|
(v.mimeType as string).trim().length > 0 &&
|
|
typeof sizeBytes === 'number' &&
|
|
Number.isFinite(sizeBytes) &&
|
|
sizeBytes >= 0 &&
|
|
typeof v.originalName === 'string' &&
|
|
(v.originalName as string).trim().length > 0 &&
|
|
typeof v.createdAt === 'string' &&
|
|
(v.createdAt as string).trim().length > 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract validated attachments from a message's metadata.
|
|
* Returns sorted by index for consistent display order.
|
|
*/
|
|
function getMessageAttachments(msg: AgentMessage): AttachmentMetadata[] {
|
|
const meta = (msg.metadata ?? {}) as AgentMessageAttachmentMetadata;
|
|
const attachments = meta.attachments;
|
|
if (!Array.isArray(attachments)) return [];
|
|
return attachments.filter(isAttachmentMetadata).sort((a, b) => a.index - b.index);
|
|
}
|
|
|
|
/**
|
|
* Map a message to a timeline item
|
|
*/
|
|
function mapMessageToTimelineItem(msg: AgentMessage): TimelineItem | null {
|
|
const createdAt = msg.createdAt;
|
|
const requestId = msg.requestId?.trim() || undefined;
|
|
|
|
// User chat messages are displayed in thread header (title + attachments),
|
|
// so we don't create timeline items for them to avoid duplicate display.
|
|
if (msg.role === 'user' && msg.messageType === 'chat') {
|
|
return null;
|
|
}
|
|
|
|
if (msg.role === 'assistant' && msg.messageType === 'chat') {
|
|
return {
|
|
kind: 'assistant_text',
|
|
id: msg.id,
|
|
requestId,
|
|
createdAt,
|
|
messageId: msg.id,
|
|
text: msg.content,
|
|
isStreaming: msg.isStreaming === true && !msg.isFinal,
|
|
};
|
|
}
|
|
|
|
if (msg.role === 'tool' && msg.messageType === 'tool_use') {
|
|
return {
|
|
kind: 'tool_use',
|
|
id: msg.id,
|
|
requestId,
|
|
createdAt,
|
|
messageId: msg.id,
|
|
tool: presentTool(msg),
|
|
isStreaming: msg.isStreaming === true && !msg.isFinal,
|
|
};
|
|
}
|
|
|
|
if (msg.role === 'tool' && msg.messageType === 'tool_result') {
|
|
const tool = presentTool(msg);
|
|
return {
|
|
kind: 'tool_result',
|
|
id: msg.id,
|
|
requestId,
|
|
createdAt,
|
|
messageId: msg.id,
|
|
tool,
|
|
isError: tool.severity === 'error',
|
|
};
|
|
}
|
|
|
|
// Status messages
|
|
if (msg.messageType === 'status' || msg.role === 'system') {
|
|
return {
|
|
kind: 'status',
|
|
id: `status:${requestId ?? 'legacy'}:${msg.id}`,
|
|
requestId,
|
|
createdAt,
|
|
status: 'ready',
|
|
text: msg.content,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build threads from messages
|
|
*/
|
|
function buildThreads(
|
|
messages: AgentMessage[],
|
|
requestState: RequestState,
|
|
currentRequestId: string | null,
|
|
): AgentThread[] {
|
|
// Sort messages by createdAt
|
|
const sortedMessages = [...messages].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
|
|
// Group messages by requestId or legacy grouping
|
|
let legacyCounter = 0;
|
|
let currentLegacyKey: string | null = null;
|
|
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
key: string;
|
|
requestId?: string;
|
|
firstAt: string;
|
|
title?: string;
|
|
items: TimelineItem[];
|
|
attachments: AttachmentMetadata[];
|
|
/** Thread header for special message types */
|
|
header?: ThreadHeader;
|
|
}
|
|
>();
|
|
|
|
function ensureGroup(key: string, requestId: string | undefined, createdAt: string) {
|
|
if (!groups.has(key)) {
|
|
groups.set(key, { key, requestId, firstAt: createdAt, items: [], attachments: [] });
|
|
}
|
|
return groups.get(key)!;
|
|
}
|
|
|
|
for (const msg of sortedMessages) {
|
|
const rid = msg.requestId?.trim() || undefined;
|
|
|
|
// Determine group key
|
|
let key: string;
|
|
if (rid) {
|
|
key = `rid:${rid}`;
|
|
} else {
|
|
if (msg.role === 'user') {
|
|
currentLegacyKey = `legacy:${legacyCounter++}`;
|
|
}
|
|
key = currentLegacyKey ?? 'legacy:orphan';
|
|
}
|
|
|
|
const group = ensureGroup(key, rid, msg.createdAt);
|
|
|
|
// Title, attachments, and header: first user chat message in group wins
|
|
if (!group.title && msg.role === 'user' && msg.messageType === 'chat') {
|
|
const fullContent = msg.content.trim();
|
|
const attachments = getMessageAttachments(msg);
|
|
const meta = (msg.metadata ?? {}) as Record<string, unknown>;
|
|
|
|
// Extract client metadata for special message types (with runtime validation)
|
|
const rawClientMeta = meta.clientMeta;
|
|
const rawDisplayText = meta.displayText;
|
|
|
|
// Validate clientMeta structure
|
|
const clientMeta: WebEditorApplyMeta | undefined =
|
|
rawClientMeta &&
|
|
typeof rawClientMeta === 'object' &&
|
|
'kind' in rawClientMeta &&
|
|
typeof (rawClientMeta as Record<string, unknown>).kind === 'string' &&
|
|
((rawClientMeta as Record<string, unknown>).kind === 'web_editor_apply_batch' ||
|
|
(rawClientMeta as Record<string, unknown>).kind === 'web_editor_apply_single')
|
|
? (rawClientMeta as WebEditorApplyMeta)
|
|
: undefined;
|
|
|
|
const displayText = typeof rawDisplayText === 'string' ? rawDisplayText : undefined;
|
|
|
|
// Store attachments for thread header display
|
|
if (attachments.length > 0) {
|
|
group.attachments = attachments;
|
|
}
|
|
|
|
// Build thread header for special message types
|
|
if (clientMeta?.kind?.startsWith('web_editor_apply')) {
|
|
group.header = {
|
|
displayText: displayText || `Apply ${clientMeta.elementCount ?? 0} changes`,
|
|
fullContent,
|
|
webEditorApply: clientMeta,
|
|
};
|
|
// Use display text as title for web editor apply messages
|
|
group.title = displayText || `Apply ${clientMeta.elementCount ?? 0} changes`;
|
|
} else if (fullContent) {
|
|
group.title = fullContent;
|
|
} else {
|
|
// Image-only message - use attachment count as title
|
|
group.title =
|
|
attachments.length > 0
|
|
? `Sent ${attachments.length} image${attachments.length === 1 ? '' : 's'}`
|
|
: 'Untitled request';
|
|
}
|
|
|
|
group.firstAt = msg.createdAt;
|
|
}
|
|
|
|
// Map message to timeline item
|
|
const item = mapMessageToTimelineItem(msg);
|
|
if (item) group.items.push(item);
|
|
|
|
// Update earliest timestamp
|
|
if (msg.createdAt < group.firstAt) group.firstAt = msg.createdAt;
|
|
}
|
|
|
|
// Convert groups to threads
|
|
const threads: AgentThread[] = [];
|
|
|
|
for (const g of groups.values()) {
|
|
const requestId = g.requestId;
|
|
|
|
// Determine thread state based on requestState (not isStreaming)
|
|
// This ensures the thread shows as running even during tool execution
|
|
const isActiveRequest =
|
|
requestState === 'starting' || requestState === 'ready' || requestState === 'running';
|
|
|
|
let state: AgentThreadState = 'completed';
|
|
if (isActiveRequest && currentRequestId && requestId === currentRequestId) {
|
|
// Map requestState to thread state
|
|
state = requestState === 'running' ? 'running' : 'starting';
|
|
} else if (g.items.some((item) => item.kind === 'status')) {
|
|
state = 'idle';
|
|
}
|
|
|
|
// Sort items by createdAt
|
|
const items = [...g.items].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
|
|
// Add status item for active requests
|
|
// Use stable ID without Date.now() to prevent component remount on each render
|
|
if (state === 'running' || state === 'starting') {
|
|
const statusText = state === 'running' ? 'Working...' : 'Starting...';
|
|
items.push({
|
|
kind: 'status',
|
|
id: `status:streaming:${requestId ?? 'current'}`,
|
|
requestId,
|
|
createdAt: new Date().toISOString(),
|
|
status: state,
|
|
text: statusText,
|
|
});
|
|
}
|
|
|
|
threads.push({
|
|
id: g.key,
|
|
requestId,
|
|
title: g.title ?? 'Untitled request',
|
|
createdAt: g.firstAt,
|
|
state,
|
|
items,
|
|
attachments: g.attachments,
|
|
header: g.header,
|
|
});
|
|
}
|
|
|
|
// Sort threads by createdAt
|
|
return threads.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
}
|
|
|
|
/**
|
|
* Composable for managing agent threads
|
|
*/
|
|
export function useAgentThreads(options: UseAgentThreadsOptions) {
|
|
const threads = computed(() => {
|
|
return buildThreads(
|
|
options.messages.value,
|
|
options.requestState.value,
|
|
options.currentRequestId.value,
|
|
);
|
|
});
|
|
|
|
return {
|
|
threads,
|
|
};
|
|
}
|