diff --git a/extensions/github1s-ai/assets/chat.css b/extensions/github1s-ai/assets/chat.css index 634822834..dc95f14cf 100644 --- a/extensions/github1s-ai/assets/chat.css +++ b/extensions/github1s-ai/assets/chat.css @@ -693,7 +693,7 @@ summary:focus-visible, } .message { - padding: 10px 0; + padding: 8px 0; } .message-user { @@ -746,6 +746,67 @@ summary:focus-visible, margin-top: 0.75em; } +.message-footer { + display: flex; + min-height: 20px; + align-items: center; + gap: 12px; + margin-top: 8px; + opacity: 0; + pointer-events: none; +} + +.message-meta { + display: flex; + min-width: 0; + flex: 1; + justify-content: flex-end; + flex-wrap: wrap; + gap: 2px 8px; + color: var(--vscode-descriptionForeground); + font-size: 0.8em; + line-height: 1.4; + text-align: right; +} + +.message-model, +.message-meta time { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-actions { + display: flex; + flex: none; + gap: 2px; +} + +.message-actions .icon-button { + width: 20px; + height: 20px; + border-radius: 3px; +} + +.message-actions .icon { + width: 13px; + height: 13px; +} + +.message-assistant:hover .message-footer, +.message-assistant:focus-within .message-footer { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none) { + .message-footer { + opacity: 1; + pointer-events: auto; + } +} + /* Markdown */ .markdown { @@ -1186,16 +1247,6 @@ summary:focus-visible, font: inherit; } -.message-retry { - gap: 4px; - margin-top: 6px; -} - -.message-retry .icon { - width: 12px; - height: 12px; -} - .message-status { margin: 5px 0 0; color: var(--vscode-descriptionForeground); @@ -1249,6 +1300,22 @@ summary:focus-visible, line-height: 1.4; } +.notice-success { + display: flex; + flex: none; + align-items: flex-start; + gap: 6px; + padding: 8px 0; + color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); +} + +.notice-success .icon { + width: 14px; + height: 14px; + flex: none; + margin-top: 1px; +} + .composer-surface { padding: 7px 7px 6px; background: var(--vscode-input-background); diff --git a/extensions/github1s-ai/src/common/conversation.ts b/extensions/github1s-ai/src/common/conversation.ts index c9f5455a2..954223f45 100644 --- a/extensions/github1s-ai/src/common/conversation.ts +++ b/extensions/github1s-ai/src/common/conversation.ts @@ -16,6 +16,8 @@ export type ConversationMessageStatus = 'streaming' | 'completed' | 'failed' | ' export interface ConversationMessageMetadata { turnId: string; status: ConversationMessageStatus; + modelId?: string; + createdAt?: number; error?: string; } @@ -39,17 +41,6 @@ export const isInterruptedMessage = (message: ConversationMessage): boolean => message.metadata.status === 'aborted' || message.metadata.status === 'unknown'; -export const getRetryableMessage = (messages: readonly ConversationMessage[]): ConversationMessage | undefined => { - const assistant = messages.at(-1); - const user = messages.at(-2); - return assistant?.role === 'assistant' && - isInterruptedMessage(assistant) && - user?.role === 'user' && - user.metadata.turnId === assistant.metadata.turnId - ? assistant - : undefined; -}; - export const createUserMessage = ( turnId: string, text: string, @@ -67,10 +58,13 @@ export const createUserMessage = ( return { id: turnId, role: 'user', metadata: { turnId, status: 'completed' }, parts }; }; -export const createAssistantMessage = (id: string, turnId: string): ConversationMessage => ({ +export const createAssistantMessage = ( + id: string, + metadata: Omit, +): ConversationMessage => ({ id, role: 'assistant', - metadata: { turnId, status: 'streaming' }, + metadata: { ...metadata, status: 'streaming' }, parts: [], }); @@ -78,14 +72,10 @@ export const withMessageStatus = ( message: ConversationMessage, status: ConversationMessageStatus, error?: string, -): ConversationMessage => ({ - ...message, - metadata: { - turnId: message.metadata.turnId, - status, - ...(error ? { error } : {}), - }, -}); +): ConversationMessage => { + const { error: _previousError, ...metadata } = message.metadata; + return { ...message, metadata: { ...metadata, status, ...(error ? { error } : {}) } }; +}; export const markStreamingMessagesUnknown = (conversation: Conversation): Conversation => { const messages = conversation.messages.map((message) => @@ -104,6 +94,8 @@ const metadataSchema = jsonSchema( properties: { turnId: { type: 'string', minLength: 1 }, status: { enum: ['streaming', 'completed', 'failed', 'aborted', 'unknown'] }, + modelId: { type: 'string' }, + createdAt: { type: 'number' }, error: { type: 'string' }, }, }, @@ -169,7 +161,7 @@ const isConversationMessageMetadata = (value: unknown): value is ConversationMes if (!isPlainObject(value)) return false; const metadata = value as Record; return ( - Object.keys(metadata).every((key) => key === 'turnId' || key === 'status' || key === 'error') && + Object.keys(metadata).every((key) => ['turnId', 'status', 'modelId', 'createdAt', 'error'].includes(key)) && typeof metadata.turnId === 'string' && metadata.turnId.length > 0 && (metadata.status === 'streaming' || @@ -177,6 +169,9 @@ const isConversationMessageMetadata = (value: unknown): value is ConversationMes metadata.status === 'failed' || metadata.status === 'aborted' || metadata.status === 'unknown') && + (metadata.modelId === undefined || typeof metadata.modelId === 'string') && + (metadata.createdAt === undefined || + (typeof metadata.createdAt === 'number' && Number.isFinite(metadata.createdAt))) && (metadata.error === undefined || typeof metadata.error === 'string') ); }; diff --git a/extensions/github1s-ai/src/common/protocol.ts b/extensions/github1s-ai/src/common/protocol.ts index ceaa4a8e2..e176d494d 100644 --- a/extensions/github1s-ai/src/common/protocol.ts +++ b/extensions/github1s-ai/src/common/protocol.ts @@ -22,8 +22,11 @@ export type { Notice } from './state'; export const MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH = 100_000; +export type ChatPhase = 'idle' | 'preparing' | 'generating'; + export interface ViewState { runtime: RuntimeState; + chatPhase: ChatPhase; promptsConfig: PromptsConfig; modelConfigs: { selectedId?: string; @@ -43,6 +46,7 @@ type AllViewEvents = | { type: 'chat.send'; text: string } | { type: 'chat.setEditingMessageId'; id?: string } | { type: 'chat.retry'; id: string } + | { type: 'chat.fork'; id: string } | { type: 'chat.runQuickAction'; action: ChatQuickAction } | { type: 'chat.addContextAttachment'; action: ContextAttachmentAction } | { type: 'chat.addContextAttachment'; action: 'descriptor'; descriptor: ContextAttachmentDescriptor } @@ -103,6 +107,7 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => { : undefined; case 'chat.retry': + case 'chat.fork': case 'chat.removeContextAttachment': case 'history.selectConversation': case 'history.deleteConversation': diff --git a/extensions/github1s-ai/src/common/state.ts b/extensions/github1s-ai/src/common/state.ts index df937ee20..91a621edf 100644 --- a/extensions/github1s-ai/src/common/state.ts +++ b/extensions/github1s-ai/src/common/state.ts @@ -20,7 +20,6 @@ export interface RuntimeState { pendingAttachments: ContextAttachmentDescriptor[]; includeRecentFiles: boolean; }; - preparing?: boolean; notice?: Notice; }; history: { diff --git a/extensions/github1s-ai/src/controllers/app.ts b/extensions/github1s-ai/src/controllers/app.ts index 2aa805303..d557f836c 100644 --- a/extensions/github1s-ai/src/controllers/app.ts +++ b/extensions/github1s-ai/src/controllers/app.ts @@ -30,6 +30,7 @@ export class AppController extends Controller { @Controller.handler('app.newChat') async handleNewChat(_event: ViewEvent<'app.newChat'>): Promise { + await this.runner.cancelPreparation(); await this.stores.conversations.select(undefined); await this.openPage('chat', true); } diff --git a/extensions/github1s-ai/src/controllers/chat.ts b/extensions/github1s-ai/src/controllers/chat.ts index ad60e2cfd..4cf43b272 100644 --- a/extensions/github1s-ai/src/controllers/chat.ts +++ b/extensions/github1s-ai/src/controllers/chat.ts @@ -26,7 +26,7 @@ export class ChatController extends Controller { @Controller.handler('chat.setEditingMessageId') async handleSetEditingMessageId(event: ViewEvent<'chat.setEditingMessageId'>): Promise { const { chat } = await this.stores.runtime.get(); - if (chat.preparing || this.runner.isRequestActive(chat.conversation?.id)) return; + if (this.runner.getPhase(chat.conversation?.id) !== 'idle') return; if (event.id === undefined) { await this.stores.runtime.setIn('chat', { ...chat, editing: undefined, notice: undefined }); return; @@ -52,6 +52,11 @@ export class ChatController extends Controller { await this.runner.retry(event.id); } + @Controller.handler('chat.fork') + async handleFork(event: ViewEvent<'chat.fork'>): Promise { + await this.runner.fork(event.id); + } + @Controller.handler('chat.cancel') async handleCancel(_event: ViewEvent<'chat.cancel'>): Promise { await this.runner.cancelCurrent(); diff --git a/extensions/github1s-ai/src/controllers/index.ts b/extensions/github1s-ai/src/controllers/index.ts index 169e40213..27168ccf9 100644 --- a/extensions/github1s-ai/src/controllers/index.ts +++ b/extensions/github1s-ai/src/controllers/index.ts @@ -78,6 +78,7 @@ export class Controllers { const mcpConfig = runtime.page === 'settings' ? await this.stores.mcpConfig.get() : undefined; return { runtime, + chatPhase: this.runner.getPhase(runtime.chat.conversation?.id), promptsConfig, modelConfigs: { configs, selectedId: selectedModel?.id }, conversations, diff --git a/extensions/github1s-ai/src/controllers/runner.ts b/extensions/github1s-ai/src/controllers/runner.ts index 8fe6e344a..3ffbdba2f 100644 --- a/extensions/github1s-ai/src/controllers/runner.ts +++ b/extensions/github1s-ai/src/controllers/runner.ts @@ -4,12 +4,12 @@ import { addLanguageModelUsage, createNullLanguageModelUsage } from 'ai/internal import { createAssistantMessage, createUserMessage, - getRetryableMessage, withMessageStatus, type Conversation, type ConversationMessage, } from '@/common/conversation'; import { resolvePrompts } from '@/common/prompts-config'; +import type { ChatPhase } from '@/common/protocol'; import type { RuntimeState } from '@/common/state'; import { buildModelMessages } from '@/llm/helpers'; import { connectMcpTools } from '@/llm/mcp'; @@ -31,7 +31,7 @@ type MessagePreparation = { export class ConversationRunner { private readonly requests = new Map(); - private preparation: symbol | undefined; + private preparation: AbortController | undefined; constructor( private readonly stores: Stores, @@ -56,15 +56,55 @@ export class ConversationRunner { async retry(messageId: string): Promise { return this.run((chat) => { const messages = chat.conversation?.messages ?? []; - if (getRetryableMessage(messages)?.id !== messageId) return; + const index = messages.findIndex((message) => message.id === messageId && message.role === 'assistant'); + const assistant = messages[index]; + const user = messages[index - 1]; + if ( + !assistant || + assistant.metadata.status === 'streaming' || + user?.role !== 'user' || + user.metadata.turnId !== assistant.metadata.turnId + ) + return; return { - history: messages.slice(0, -2), - createMessage: async () => messages[messages.length - 2], + history: messages.slice(0, index - 1), + createMessage: async () => user, clearPendingAttachments: false, }; }); } + async fork(messageId: string): Promise { + return this.withPreparation('Unable to fork the conversation.', async (chat, { signal }) => { + const previous = chat.conversation; + if (!previous || chat.editing) return; + const index = previous.messages.findIndex((message) => message.id === messageId && message.role === 'assistant'); + if (index < 0 || previous.messages[index].metadata.status === 'streaming') return; + const now = Date.now(); + const conversation: Conversation = { + id: globalThis.crypto.randomUUID(), + title: `Fork: ${previous.title}`, + createdAt: now, + updatedAt: now, + messages: structuredClone(previous.messages.slice(0, index + 1)), + }; + await this.stores.conversations.append(conversation); + signal.throwIfAborted(); + await this.stores.conversations.select(conversation.id); + const runtime = await this.stores.runtime.get(); + if (signal.aborted || runtime.page !== 'chat' || runtime.chat.conversation?.id !== previous.id) { + await this.stores.conversations.select(runtime.chat.conversation?.id, conversation.id); + return; + } + await this.stores.runtime.setIn('chat', { + ...runtime.chat, + conversation, + pendingAttachments: [], + notice: { severity: 'success', message: "You're now in a forked conversation." }, + }); + }); + } + async edit(text: string): Promise { if (!text.trim()) return; return this.run((chat) => { @@ -100,71 +140,48 @@ export class ConversationRunner { } private async run(prepare: (chat: RuntimeState['chat']) => MessagePreparation | undefined): Promise { - if (this.preparation) return; - - const [promptsConfig, config] = await Promise.all([ - this.stores.promptsConfig.get(), - this.stores.modelConfigs.getSelected(), - ]); - const prompts = resolvePrompts(promptsConfig); - - if (!config) { - await this.stores.runtime.setIn('chat.notice', { - severity: 'error', - message: 'Select a model before sending a message.', + return this.withPreparation('Unable to prepare the conversation.', async (chat, controller) => { + const { signal } = controller; + const previous = chat.conversation; + const prepared = prepare(chat); + if (!prepared) return; + const [promptsConfig, config] = await Promise.all([ + this.stores.promptsConfig.get(), + this.stores.modelConfigs.getSelected(), + ]); + signal.throwIfAborted(); + if (!config) { + await this.stores.runtime.setIn('chat.notice', { + severity: 'error', + message: 'Select a model before sending a message.', + }); + return; + } + const prompts = resolvePrompts(promptsConfig); + const conversationId = previous?.id ?? globalThis.crypto.randomUUID(); + const userMessage = await prepared.createMessage(); + signal.throwIfAborted(); + const startedAt = Date.now(); + const providerMessages = await buildModelMessages(prepared.history, [userMessage]); + signal.throwIfAborted(); + const assistantMessage = createAssistantMessage(globalThis.crypto.randomUUID(), { + turnId: userMessage.metadata.turnId, + modelId: config.modelId, + createdAt: startedAt, }); - return; - } - const runtime = await this.stores.runtime.get(); - if (this.preparation) return; - const previous = runtime.chat.conversation; - const prepared = prepare(runtime.chat); - if (!prepared) return; - const conversationId = previous?.id ?? globalThis.crypto.randomUUID(); - if (this.requests.has(conversationId)) return; - const preparation = Symbol('conversation-preparation'); - this.preparation = preparation; - await this.stores.runtime.setIn('chat', { ...runtime.chat, preparing: true, notice: undefined }); - - let userMessage: ConversationMessage; - try { - await this.publishState(); - userMessage = await prepared.createMessage(); - } catch (error) { - const detail = error instanceof Error ? error.message : 'Unknown error.'; - await this.finishPreparation(preparation, `Unable to read the selected context attachment. ${detail}`); - return; - } - if (this.preparation !== preparation) return; - - const startedAt = Date.now(); - const turnId = userMessage.metadata.turnId; - const history = prepared.history; - let providerMessages; - try { - providerMessages = await buildModelMessages(history, [userMessage]); - } catch (error) { - const detail = error instanceof Error ? error.message : 'Unknown error.'; - await this.finishPreparation(preparation, `Unable to prepare the conversation. ${detail}`); - return; - } - if (this.preparation !== preparation) return; - - const assistantMessage = createAssistantMessage(globalThis.crypto.randomUUID(), turnId); - const conversation: Conversation = previous - ? { - ...previous, - messages: [...history, userMessage, assistantMessage], - updatedAt: startedAt, - } - : { - id: conversationId, - title: createConversationTitle(userMessage), - messages: [userMessage, assistantMessage], - createdAt: startedAt, - updatedAt: startedAt, - }; - try { + const conversation: Conversation = previous + ? { + ...previous, + messages: [...prepared.history, userMessage, assistantMessage], + updatedAt: startedAt, + } + : { + id: conversationId, + title: createConversationTitle(userMessage), + messages: [userMessage, assistantMessage], + createdAt: startedAt, + updatedAt: startedAt, + }; if (previous) { await this.stores.conversations.update(conversationId, { messages: conversation.messages, @@ -173,66 +190,54 @@ export class ConversationRunner { } else { await this.stores.conversations.append(conversation); } - } catch (error) { - const detail = error instanceof Error ? error.message : 'Unknown error.'; - await this.finishPreparation(preparation, `Unable to save the conversation. ${detail}`); - return; - } - if (this.preparation !== preparation) { - await this.setMessageStatus(conversationId, assistantMessage.id, 'aborted'); - return; - } - - this.preparation = undefined; - const abortController = new AbortController(); - const request: ActiveRequest = { - controller: abortController, - messageId: assistantMessage.id, - }; - this.requests.set(conversationId, request); - let mcp: Awaited> | undefined; - try { - const currentRuntime = await this.stores.runtime.get(); - if (currentRuntime.chat.conversation?.id === previous?.id) { - await this.stores.runtime.setIn('chat', { - ...currentRuntime.chat, - conversation, - pendingAttachments: prepared.clearPendingAttachments ? [] : currentRuntime.chat.pendingAttachments, - editing: undefined, - preparing: false, - }); - await this.stores.conversations.select(conversationId); - await this.publishState(); - } - mcp = await connectMcpTools(await this.stores.mcpConfig.get(), abortController.signal); - abortController.signal.throwIfAborted(); - const agentStream = streamLLMAgent({ ...config }, providerMessages, abortController.signal, { - instructions: prompts.instructions, - responseMessage: assistantMessage, - tools: mcp.tools, - }); - for await (const message of agentStream.stream) { - if (abortController.signal.aborted || this.requests.get(conversationId) !== request) break; - await this.persistAssistantMessage(conversationId, request, withMessageStatus(message, 'streaming')); + if (signal.aborted) { + await this.setMessageStatus(conversationId, assistantMessage.id, 'aborted'); + return; } - if (!abortController.signal.aborted && this.requests.get(conversationId) === request) { - const usage = await agentStream.usage; - if (!abortController.signal.aborted && this.requests.get(conversationId) === request) { - await this.setMessageStatus(conversationId, request.messageId, 'completed', undefined, usage); + const request: ActiveRequest = { controller, messageId: assistantMessage.id }; + this.requests.set(conversationId, request); + this.preparation = undefined; + let mcp: Awaited> | undefined; + try { + const currentRuntime = await this.stores.runtime.get(); + signal.throwIfAborted(); + if (currentRuntime.chat.conversation?.id === previous?.id) { + await this.stores.runtime.setIn('chat', { + ...currentRuntime.chat, + conversation, + pendingAttachments: prepared.clearPendingAttachments ? [] : currentRuntime.chat.pendingAttachments, + editing: undefined, + }); + await this.stores.conversations.select(conversationId); + await this.publishState(); } + mcp = await connectMcpTools(await this.stores.mcpConfig.get(), signal); + signal.throwIfAborted(); + const agentStream = streamLLMAgent({ ...config }, providerMessages, signal, { + instructions: prompts.instructions, + responseMessage: assistantMessage, + tools: mcp.tools, + }); + for await (const message of agentStream.stream) { + if (signal.aborted) break; + await this.persistAssistantMessage(conversationId, request, withMessageStatus(message, 'streaming')); + } + if (!signal.aborted) { + const usage = await agentStream.usage; + if (!signal.aborted) { + await this.setMessageStatus(conversationId, request.messageId, 'completed', undefined, usage); + } + } + } catch (error) { + if (!signal.aborted) { + const message = error instanceof Error ? error.message : 'Unknown error.'; + await this.setMessageStatus(conversationId, request.messageId, 'failed', message); + } + } finally { + if (this.requests.get(conversationId) === request) this.requests.delete(conversationId); + await Promise.all([this.publishState(), mcp?.close()]); } - } catch (error) { - if (this.requests.get(conversationId) !== request) return; - if (abortController.signal.aborted) { - await this.setMessageStatus(conversationId, request.messageId, 'aborted'); - } else { - const message = error instanceof Error ? error.message : 'Unknown error.'; - await this.setMessageStatus(conversationId, request.messageId, 'failed', message); - } - } finally { - if (this.requests.get(conversationId) === request) this.requests.delete(conversationId); - await mcp?.close(); - } + }); } async cancelCurrent(): Promise { @@ -256,8 +261,16 @@ export class ConversationRunner { return conversationId !== undefined && this.requests.has(conversationId); } + getPhase(conversationId: string | undefined): ChatPhase { + if (this.preparation) return 'preparing'; + return this.isRequestActive(conversationId) ? 'generating' : 'idle'; + } + async cancelPreparation(): Promise { - if (this.preparation) await this.finishPreparation(this.preparation); + if (!this.preparation) return; + this.preparation.abort(); + this.preparation = undefined; + await this.publishState(); } async cancelAll(): Promise { @@ -272,13 +285,13 @@ export class ConversationRunner { request: ActiveRequest, message: ConversationMessage, ): Promise { - if (this.requests.get(conversationId) !== request) return; + if (request.controller.signal.aborted) return; const conversation = await this.stores.conversations.get(conversationId); - if (!conversation || this.requests.get(conversationId) !== request) return; + if (!conversation || request.controller.signal.aborted) return; const messages = conversation.messages.map((candidate) => (candidate.id === message.id ? message : candidate)); await this.stores.conversations.update(conversationId, { messages }); const updated = { ...conversation, messages }; - await this.setCurrentConversation(updated); + await this.setCurrentConversation(updated, request.controller.signal); await this.publishState(); } @@ -305,21 +318,34 @@ export class ConversationRunner { await this.publishState(); } - private async setCurrentConversation(conversation: Conversation): Promise { + private async setCurrentConversation(conversation: Conversation, signal?: AbortSignal): Promise { const runtime = await this.stores.runtime.get(); - if (runtime.chat.conversation?.id !== conversation.id) return; + if (runtime.chat.conversation?.id !== conversation.id || signal?.aborted) return; await this.stores.runtime.setIn('chat.conversation', conversation); } - private async finishPreparation(preparation: symbol, message?: string): Promise { - if (this.preparation !== preparation) return; - this.preparation = undefined; - const runtime = await this.stores.runtime.get(); - await this.stores.runtime.setIn('chat', { - ...runtime.chat, - preparing: false, - ...(message ? { notice: { severity: 'error', message } } : {}), - }); + private async withPreparation( + failureMessage: string, + operation: (chat: RuntimeState['chat'], controller: AbortController) => Promise, + ): Promise { + const { chat } = await this.stores.runtime.get(); + if (this.getPhase(chat.conversation?.id) !== 'idle') return; + const controller = new AbortController(); + this.preparation = controller; + try { + await this.stores.runtime.setIn('chat.notice', undefined); + await this.publishState(); + controller.signal.throwIfAborted(); + await operation(chat, controller); + } catch (error) { + if (!controller.signal.aborted) { + const detail = error instanceof Error ? error.message : 'Unknown error.'; + await this.stores.runtime.setIn('chat.notice', { severity: 'error', message: `${failureMessage} ${detail}` }); + } + } finally { + if (this.preparation === controller) this.preparation = undefined; + await this.publishState(); + } } } diff --git a/extensions/github1s-ai/src/stores/conversations.ts b/extensions/github1s-ai/src/stores/conversations.ts index 3108fff88..876560797 100644 --- a/extensions/github1s-ai/src/stores/conversations.ts +++ b/extensions/github1s-ai/src/stores/conversations.ts @@ -55,9 +55,10 @@ export class ConversationsStore { return selectedId === undefined ? undefined : this.get(selectedId); } - async select(id: string | undefined): Promise { + async select(id: string | undefined, expectedId?: string): Promise { return this.enqueue(async () => { const index = await this.readIndex(); + if (expectedId !== undefined && index.selectedId !== expectedId) return; if (id !== undefined && !index.summaries.some((conversation) => conversation.id === id)) { throw new Error(`Conversation with id ${id} not found.`); } diff --git a/extensions/github1s-ai/src/webview/components/ChatPage.ts b/extensions/github1s-ai/src/webview/components/ChatPage.ts index 31f5c385f..c8473cfbf 100644 --- a/extensions/github1s-ai/src/webview/components/ChatPage.ts +++ b/extensions/github1s-ai/src/webview/components/ChatPage.ts @@ -1,7 +1,7 @@ import { html } from 'htm/preact'; import { useLayoutEffect, useRef } from 'preact/hooks'; -import { getRetryableMessage, type ConversationMessage } from '@/common/conversation'; +import type { ConversationMessage } from '@/common/conversation'; import type { ViewEvent, ViewState } from '@/common/protocol'; import { QUICK_ACTIONS } from '@/common/quick-actions'; @@ -25,37 +25,28 @@ export const ChatPage = ({ state, active, post }: ChatPageProps) => { const conversation = state.runtime.chat.conversation; const conversationId = conversation?.id; const messages = conversation?.messages ?? EMPTY_MESSAGES; - const busy = - state.runtime.chat.preparing === true || messages.some(({ metadata }) => metadata.status === 'streaming'); - const notice = state.runtime.chat.notice?.message; + const notice = state.runtime.chat.notice; const editingId = state.runtime.chat.editing?.messageId; return html` - ${notice && !editingId ? html`

${notice}

` : null} - <${Transcript} state=${state} messages=${messages} busy=${busy} active=${active} post=${post} /> + ${notice && !editingId + ? html`

+ ${notice.severity === 'success' ? html`<${Icon} name="check" />` : null} ${notice.message} +

` + : null} + <${Transcript} state=${state} messages=${messages} active=${active} post=${post} /> ${state.modelConfigs.configs.length > 0 - ? html`<${Composer} - key=${conversationId ?? 'new'} - state=${state} - busy=${busy} - active=${active && !editingId} - post=${post} - />` + ? html`<${Composer} key=${conversationId ?? 'new'} state=${state} active=${active && !editingId} post=${post} />` : null} `; }; -interface ChatContentProps extends ChatContextProps { - busy: boolean; -} - -interface TranscriptProps extends ChatContentProps { +interface TranscriptProps extends ChatContextProps { messages: readonly ConversationMessage[]; active: boolean; } -const Transcript = ({ state, messages, busy, active, post }: TranscriptProps) => { - const retryableMessage = busy ? undefined : getRetryableMessage(messages); +const Transcript = ({ state, messages, active, post }: TranscriptProps) => { const editingId = state.runtime.chat.editing?.messageId; const lastMessageBeforeEditing = useRef(); const transcript = useRef(null); @@ -98,17 +89,17 @@ const Transcript = ({ state, messages, busy, active, post }: TranscriptProps) => return html`
${messages.length === 0 - ? html`<${EmptyChat} state=${state} busy=${busy} post=${post} />` + ? html`<${EmptyChat} state=${state} post=${post} />` : messages.map((message) => message.id === editingId ? html`
- <${Composer} state=${state} busy=${busy} active=${active} post=${post} message=${message} /> + <${Composer} state=${state} active=${active} post=${post} message=${message} />
` : html`<${MessageView} key=${message.id} message=${message} - canRetry=${message === retryableMessage} - canEdit=${!busy && !editingId && state.modelConfigs.configs.length > 0} + busy=${state.chatPhase !== 'idle'} + hasModel=${state.modelConfigs.configs.length > 0} inactive=${Boolean(editingId)} post=${post} />`, @@ -121,7 +112,7 @@ const scrollToBottom = (element: HTMLElement): void => { element.scrollTop = element.scrollHeight; }; -const EmptyChat = ({ state, busy, post }: ChatContentProps) => { +const EmptyChat = ({ state, post }: ChatContextProps) => { if (state.modelConfigs.configs.length === 0) { return html`

Set up GitHub1s AI

@@ -133,11 +124,11 @@ const EmptyChat = ({ state, busy, post }: ChatContentProps) => { return html`

Ask about this repository

Choose a quick action or ask a question below.

- <${QuickActions} state=${state} busy=${busy} post=${post} /> + <${QuickActions} state=${state} post=${post} />
`; }; -const QuickActions = ({ state, busy, post }: ChatContentProps) => { +const QuickActions = ({ state, post }: ChatContextProps) => { const chat = state.runtime.chat; return html`
${QUICK_ACTIONS.map((item) => { @@ -146,7 +137,7 @@ const QuickActions = ({ state, busy, post }: ChatContentProps) => { key=${item.action} class="quick-action" type="button" - disabled=${busy || !available} + disabled=${state.chatPhase !== 'idle' || !available} onClick=${() => post({ type: 'chat.runQuickAction', action: item.action })} > <${Icon} name=${item.icon} /> diff --git a/extensions/github1s-ai/src/webview/components/Composer.ts b/extensions/github1s-ai/src/webview/components/Composer.ts index f7816830e..f2f6946f7 100644 --- a/extensions/github1s-ai/src/webview/components/Composer.ts +++ b/extensions/github1s-ai/src/webview/components/Composer.ts @@ -5,7 +5,7 @@ import type { ConversationMessage } from '@/common/conversation'; import type { ViewEvent, ViewState } from '@/common/protocol'; import { resizeComposer, shouldSubmitComposer } from '../helpers/composer'; -import { presentConversationUsage } from '../helpers/presentation'; +import { getMessageText, presentConversationUsage } from '../helpers/presentation'; import { AttachmentChips } from './AttachmentChips'; import { Icon } from './Icon'; import { RecentFilesTooltip } from './RecentFiles'; @@ -33,20 +33,18 @@ const ATTACHMENT_ACTIONS = [ interface ComposerProps { state: ViewState; - busy: boolean; active: boolean; post: (event: ViewEvent) => void; message?: ConversationMessage; } -export const Composer = ({ state, busy, active, post, message: editedMessage }: ComposerProps) => { - const [draft, setDraft] = useState(() => - editedMessage ? editedMessage.parts.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') : '', - ); +export const Composer = ({ state, active, post, message: editedMessage }: ComposerProps) => { + const [draft, setDraft] = useState(() => (editedMessage ? getMessageText(editedMessage) : '')); const textarea = useRef(null); const chat = state.runtime.chat; const context = editedMessage ? (chat.editing ?? chat) : chat; - const preparing = chat.preparing === true; + const preparing = state.chatPhase === 'preparing'; + const busy = state.chatPhase !== 'idle'; const recentFiles = editedMessage ? editedMessage.parts.flatMap((part) => (part.type === 'data-recentFiles' ? part.data : [])) : (chat.recentFiles ?? []); diff --git a/extensions/github1s-ai/src/webview/components/Icon.ts b/extensions/github1s-ai/src/webview/components/Icon.ts index 0bd98f943..43e549768 100644 --- a/extensions/github1s-ai/src/webview/components/Icon.ts +++ b/extensions/github1s-ai/src/webview/components/Icon.ts @@ -6,9 +6,11 @@ const ICON_PATHS = { check: 'M13.2 4.3 6.5 11 2.8 7.3l.8-.8 2.9 2.9 5.9-5.9z', 'chevron-right': 'M5.7 3.3 10.4 8l-4.7 4.7-.8-.8L8.8 8 4.9 4.1z', close: 'M4 4l8 8m0-8-8 8', + copy: 'M5 1h9v11h-3v3H2V4h3zm1 3h5v7h2V2H6zM3 5v9h7V5z', download: 'M7.5 1h1v8.1l2.8-2.8.7.7-4 4-4-4 .7-.7 2.8 2.8zM2 12h1v2h10v-2h1v3H2z', edit: 'M10.9 2.1a2.1 2.1 0 0 1 3 3L6 13q-.5.5-1.1.6l-3.4.9.9-3.4q.1-.6.6-1.1zM10.1 3.6l2.3 2.3', file: 'M3 1h6l4 4v10H3zm6 1.4V5h2.6zM4 2v12h8V6H8V2z', + fork: 'M4 4.5v7m0-3.5h5a3 3 0 0 0 3-3v-.5M4 1.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3m8 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3M4 11.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3', history: 'M8 1a7 7 0 1 1-6.7 9h1.06a6 6 0 1 0 .44-5H5v1H1V2h1v2.4A7 7 0 0 1 8 1m-.5 3h1v4.2l2.85 1.65-.5.86L7.5 8.8z', refresh: 'M8 1a7 7 0 1 1-6.7 9h1.06a6 6 0 1 0 .44-5H5v1H1V2h1v2.4A7 7 0 0 1 8 1', repo: 'M2 2h5l1 2h6v10H2zm1 1v10h10V5H7.4l-1-2z', @@ -29,7 +31,7 @@ interface IconProps { } export const Icon = ({ name, className }: IconProps) => { - const stroked = name === 'close' || name === 'edit'; + const stroked = name === 'close' || name === 'edit' || name === 'fork'; return html` void; } -const COPY_FEEDBACK_DURATION_MS = 1_600; let diagramSequence = 0; let mermaidTheme: string | undefined; @@ -170,18 +170,13 @@ const copyCodeBlock = async (event: MouseEvent): Promise => { const button = target.closest('button[data-copy-code]'); if (!button || !root.contains(button)) return; const code = button.closest('.code-block')?.querySelector('code')?.textContent; - if (code === undefined || !navigator.clipboard) return; + if (code === undefined || !(await copyText(code || ''))) return; - try { - await navigator.clipboard.writeText(code || ''); - button.textContent = 'Copied'; - button.setAttribute('aria-label', 'Code copied'); - window.setTimeout(() => { - if (!button.isConnected) return; - button.textContent = 'Copy'; - button.setAttribute('aria-label', 'Copy code'); - }, COPY_FEEDBACK_DURATION_MS); - } catch { - // Clipboard access can be disabled by the webview host. Keep the code selectable. - } + button.textContent = 'Copied'; + button.setAttribute('aria-label', 'Code copied'); + window.setTimeout(() => { + if (!button.isConnected) return; + button.textContent = 'Copy'; + button.setAttribute('aria-label', 'Copy code'); + }, COPY_FEEDBACK_DURATION_MS); }; diff --git a/extensions/github1s-ai/src/webview/components/MessageView.ts b/extensions/github1s-ai/src/webview/components/MessageView.ts index 32db019c3..06ba2c31c 100644 --- a/extensions/github1s-ai/src/webview/components/MessageView.ts +++ b/extensions/github1s-ai/src/webview/components/MessageView.ts @@ -1,14 +1,17 @@ import { html } from 'htm/preact'; +import { useEffect, useState } from 'preact/hooks'; import type { ConversationMessage } from '@/common/conversation'; import type { ViewEvent } from '@/common/protocol'; -import { messageStatusLabel } from '../helpers/presentation'; +import { copyText, COPY_FEEDBACK_DURATION_MS } from '../helpers/clipboard'; +import { formatConversationTime, getMessageText, messageStatusLabel } from '../helpers/presentation'; import { AssistantActivity, isAssistantActivityPart, type AssistantActivityPart } from './AssistantActivity'; import { AttachmentChips } from './AttachmentChips'; import { Icon } from './Icon'; import { Markdown } from './Markdown'; import { RecentFilesTooltip } from './RecentFiles'; +import { Tooltip, TooltipButton } from './Tooltip'; type AssistantSegment = | { type: 'activity'; key: string; parts: AssistantActivityPart[]; followedByText: boolean } @@ -16,13 +19,16 @@ type AssistantSegment = interface MessageViewProps { message: ConversationMessage; - canRetry?: boolean; - canEdit?: boolean; + busy: boolean; + hasModel: boolean; inactive?: boolean; post: (event: ViewEvent) => void; } -export const MessageView = ({ message, canRetry, canEdit, inactive, post }: MessageViewProps) => { +export const MessageView = (props: MessageViewProps) => { + const { message, inactive, post } = props; + const [now, setNow] = useState(Date.now); + const refreshTime = () => setNow(Date.now()); if (message.role !== 'user' && message.role !== 'assistant') return null; const segments = message.role === 'assistant' ? assistantSegments(message) : []; const activityRunning = segments.some( @@ -30,9 +36,14 @@ export const MessageView = ({ message, canRetry, canEdit, inactive, post }: Mess ); const status = activityRunning ? undefined : messageStatusLabel(message.metadata); - return html`
+ return html`
${message.role === 'user' - ? html`<${UserRequest} message=${message} canEdit=${canEdit} post=${post} />` + ? html`<${UserRequest} ...${props} />` : html`
${segments.map((segment) => segment.type === 'text' @@ -48,19 +59,65 @@ export const MessageView = ({ message, canRetry, canEdit, inactive, post }: Mess
`} ${status ? html`<${MessageStatus} label=${status} generating=${message.metadata.status === 'streaming'} />` : null} ${message.metadata.error ? html`` : null} - ${canRetry - ? html`` - : null} + ${message.role === 'assistant' ? html`<${AssistantFooter} ...${props} now=${now} />` : null} `; }; +const AssistantFooter = ({ message, busy, hasModel, inactive, post, now }: MessageViewProps & { now: number }) => { + const [copied, setCopied] = useState(false); + const text = getMessageText(message, '\n\n'); + const disabled = busy || inactive; + const { modelId, createdAt } = message.metadata; + const time = createdAt === undefined ? undefined : formatConversationTime(createdAt, now); + + useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), COPY_FEEDBACK_DURATION_MS); + return () => window.clearTimeout(timer); + }, [copied]); + + return html`
+
+ <${TooltipButton} + class="icon-button" + label="Regenerate response" + disabled=${disabled || !hasModel} + onClick=${() => post({ type: 'chat.retry', id: message.id })} + > + <${Icon} name="refresh" /> + + <${TooltipButton} + class="icon-button" + label=${copied ? 'Copied' : 'Copy message'} + disabled=${!text} + onClick=${async () => setCopied(await copyText(text))} + > + <${Icon} name=${copied ? 'check' : 'copy'} /> + + <${TooltipButton} + class="icon-button" + label="Fork conversation" + disabled=${disabled} + onClick=${() => post({ type: 'chat.fork', id: message.id })} + > + <${Icon} name="fork" /> + +
+
+ ${modelId + ? html`<${Tooltip} content=${modelId}> + ${modelId} + ` + : null} + ${time?.dateTime + ? html`<${Tooltip} content=${time.dateTime}> + + ` + : null} +
+
`; +}; + const MessageStatus = ({ label, generating }: { label: string; generating: boolean }) => { if (!generating) return html`

${label}

`; return html`

@@ -100,8 +157,9 @@ const assistantSegments = (message: ConversationMessage): AssistantSegment[] => return segments; }; -const UserRequest = ({ message, canEdit, post }: MessageViewProps) => { - const text = message.parts.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join(''); +const UserRequest = ({ message, busy, hasModel, inactive, post }: MessageViewProps) => { + const canEdit = !busy && !inactive && hasModel; + const text = getMessageText(message); const attachments = message.parts.flatMap((part) => (part.type === 'data-attachments' ? part.data : [])); const recentFiles = message.parts.flatMap((part) => (part.type === 'data-recentFiles' ? part.data : [])); const edit = (event: MouseEvent | KeyboardEvent) => { diff --git a/extensions/github1s-ai/src/webview/helpers/clipboard.ts b/extensions/github1s-ai/src/webview/helpers/clipboard.ts new file mode 100644 index 000000000..c7acf2654 --- /dev/null +++ b/extensions/github1s-ai/src/webview/helpers/clipboard.ts @@ -0,0 +1,10 @@ +export const COPY_FEEDBACK_DURATION_MS = 1_600; + +export const copyText = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +}; diff --git a/extensions/github1s-ai/src/webview/helpers/presentation.ts b/extensions/github1s-ai/src/webview/helpers/presentation.ts index ee9a6aab6..6ee1ce9ca 100644 --- a/extensions/github1s-ai/src/webview/helpers/presentation.ts +++ b/extensions/github1s-ai/src/webview/helpers/presentation.ts @@ -1,6 +1,6 @@ import type { LanguageModelUsage } from 'ai'; -import type { ConversationMessageMetadata } from '@/common/conversation'; +import type { ConversationMessage, ConversationMessageMetadata } from '@/common/conversation'; const MINUTE_MS = 60_000; const HOUR_MS = 60 * MINUTE_MS; @@ -9,6 +9,9 @@ const MONTH_MS = 30 * DAY_MS; const YEAR_MS = 365 * DAY_MS; const UI_LOCALE = 'en-US'; +export const getMessageText = (message: ConversationMessage, separator = ''): string => + message.parts.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join(separator); + /** * Return the transient status text that belongs beside an assistant response. * @param metadata Message status being presented. diff --git a/extensions/github1s-ai/test/conversation.test.ts b/extensions/github1s-ai/test/conversation.test.ts index f8809f9ed..a97a953a3 100644 --- a/extensions/github1s-ai/test/conversation.test.ts +++ b/extensions/github1s-ai/test/conversation.test.ts @@ -5,20 +5,12 @@ import type { ContextAttachment } from '@/common/context'; import { createAssistantMessage, createUserMessage, - getRetryableMessage, markStreamingMessagesUnknown, validateConversationMessages, withMessageStatus, type Conversation, } from '@/common/conversation'; -test('the latest failed assistant can be retried until completed', () => { - const user = createUserMessage('turn-1', 'Hello', []); - const assistant = withMessageStatus(createAssistantMessage('assistant-1', 'turn-1'), 'failed'); - assert.equal(getRetryableMessage([user, assistant]), assistant); - assert.equal(getRetryableMessage([user, withMessageStatus(assistant, 'completed')]), undefined); -}); - test('user messages capture attachments and pass conversation validation', async () => { const attachment: ContextAttachment = { id: 'file-1', @@ -44,12 +36,13 @@ test('user messages capture attachments and pass conversation validation', async }); test('message status updates preserve content and clear stale errors without mutation', () => { - const message = createAssistantMessage('assistant-1', 'turn-1'); + const metadata = { turnId: 'turn-1', modelId: 'test-model', createdAt: 1234 }; + const message = createAssistantMessage('assistant-1', metadata); message.parts.push({ type: 'text', text: 'Partial answer' }); const failed = withMessageStatus(message, 'failed', 'Connection lost'); - assert.deepEqual(failed.metadata, { turnId: 'turn-1', status: 'failed', error: 'Connection lost' }); + assert.deepEqual(failed.metadata, { ...metadata, status: 'failed', error: 'Connection lost' }); const completed = withMessageStatus(failed, 'completed'); - assert.deepEqual(completed.metadata, { turnId: 'turn-1', status: 'completed' }); + assert.deepEqual(completed.metadata, { ...metadata, status: 'completed' }); assert.deepEqual(completed.parts, message.parts); assert.equal(message.metadata.status, 'streaming'); assert.equal(failed.metadata.error, 'Connection lost'); @@ -61,7 +54,7 @@ test('restoring a conversation marks interrupted streams as unknown', () => { title: 'Test conversation', createdAt: 1, updatedAt: 1, - messages: [createUserMessage('turn-1', 'Hello', []), createAssistantMessage('assistant-1', 'turn-1')], + messages: [createUserMessage('turn-1', 'Hello', []), createAssistantMessage('assistant-1', { turnId: 'turn-1' })], }; const restored = markStreamingMessagesUnknown(conversation); assert.deepEqual( diff --git a/extensions/github1s-ai/test/model-messages.test.ts b/extensions/github1s-ai/test/model-messages.test.ts index 67119ca9c..52adf6ae5 100644 --- a/extensions/github1s-ai/test/model-messages.test.ts +++ b/extensions/github1s-ai/test/model-messages.test.ts @@ -69,7 +69,7 @@ test('model history excludes an entire turn while its assistant is streaming', a [ ...completedTurn('old', 'Previous question'), createUserMessage('pending', 'Pending question', []), - createAssistantMessage('pending-assistant', 'pending'), + createAssistantMessage('pending-assistant', { turnId: 'pending' }), ], [createUserMessage('new', 'New question', [])], ); @@ -161,7 +161,7 @@ test('model history pairs a tool with an unknown result when execution was inter }); test('an empty failed reply gets an interruption marker before the next question', async () => { - const assistant = withMessageStatus(createAssistantMessage('assistant-1', 'turn-1'), 'failed'); + const assistant = withMessageStatus(createAssistantMessage('assistant-1', { turnId: 'turn-1' }), 'failed'); const messages = await buildModelMessages( [createUserMessage('turn-1', 'Question', []), assistant], [createUserMessage('turn-2', 'Next', [])], diff --git a/extensions/github1s-ai/test/protocol.test.ts b/extensions/github1s-ai/test/protocol.test.ts index eccff276a..94576fb2e 100644 --- a/extensions/github1s-ai/test/protocol.test.ts +++ b/extensions/github1s-ai/test/protocol.test.ts @@ -8,6 +8,7 @@ test('view events accept chat commands and context descriptors', () => { { type: 'chat.send', text: 'Explain this repository.' }, { type: 'chat.cancel' }, { type: 'chat.retry', id: 'assistant-1' }, + { type: 'chat.fork', id: 'assistant-1' }, { type: 'chat.setIncludeRecentFiles', enabled: false }, { type: 'chat.setIncludeRecentFiles', enabled: true }, { type: 'chat.runQuickAction', action: 'repositoryOverview' }, diff --git a/extensions/github1s-ai/test/runner.test.ts b/extensions/github1s-ai/test/runner.test.ts index 944b69468..39ecb4481 100644 --- a/extensions/github1s-ai/test/runner.test.ts +++ b/extensions/github1s-ai/test/runner.test.ts @@ -2,7 +2,13 @@ import assert from 'node:assert/strict'; import { registerHooks } from 'node:module'; import { before, test } from 'node:test'; -import { createAssistantMessage, createUserMessage, withMessageStatus, type Conversation } from '@/common/conversation'; +import { + createAssistantMessage, + createUserMessage, + validateConversationMessages, + withMessageStatus, + type Conversation, +} from '@/common/conversation'; import type { ModelConfig } from '@/common/model-config'; import type { Stores } from '@/stores'; import { createRuntimeStateStore } from '@/stores/runtime-state'; @@ -10,7 +16,7 @@ import { createRuntimeStateStore } from '@/stores/runtime-state'; let ConversationRunner: typeof import('@/controllers/runner').ConversationRunner; before(async () => { - // Retrying snapshots must not read VS Code documents or execute repository tools. + // Conversation actions use snapshots without reading VS Code documents or executing repository tools. const hooks = registerHooks({ resolve: (specifier, context, next) => specifier === 'vscode' ? { url: 'mock:vscode', shortCircuit: true } : next(specifier, context), @@ -33,15 +39,21 @@ const setup = async () => { [{ id: 'original-file', type: 'file', label: 'a.ts', source: 'github1s:/a.ts', content: 'original file snapshot' }], [{ source: 'github1s:/original.ts' }], ); - const assistant = withMessageStatus(createAssistantMessage('old-assistant', 'turn-1'), 'failed', 'Old error'); + const assistant = withMessageStatus( + createAssistantMessage('old-assistant', { turnId: 'turn-1' }), + 'failed', + 'Old error', + ); assistant.parts.push({ type: 'text', text: 'Old partial answer' }); - let conversation: Conversation = { + const conversation: Conversation = { id: 'conversation-1', title: 'Original title', createdAt: 1, updatedAt: 1, messages: [user, assistant], }; + const savedConversations = new Map([[conversation.id, conversation]]); + let selectedId: string | undefined = conversation.id; const runtime = createRuntimeStateStore(); await runtime.set({ page: 'chat', @@ -68,14 +80,25 @@ const setup = async () => { promptsConfig: { get: async () => ({ instructions: 'Current instructions', userRules: '', quickActions: {} }) }, mcpConfig: { get: async () => ({ mcpServers: {} }) }, conversations: { - get: async () => conversation, - update: async (_id: string, update: Partial) => { - conversation = { ...conversation, ...update }; + get: async (id: string) => savedConversations.get(id), + append: async (item: Conversation) => { + savedConversations.set(item.id, item); + }, + update: async (id: string, update: Partial) => { + savedConversations.set(id, { ...savedConversations.get(id)!, ...update }); + }, + select: async (id: string | undefined) => { + selectedId = id; }, - select: async () => {}, }, } as unknown as Stores; - return { stores, user, runner: new ConversationRunner(stores, async () => {}), getConversation: () => conversation }; + return { + stores, + user, + runner: new ConversationRunner(stores, async () => {}), + getConversation: () => savedConversations.get(conversation.id)!, + getSelectedId: () => selectedId, + }; }; const response = () => @@ -122,7 +145,101 @@ test('retry replaces the failed reply using the original context and current set assert.notEqual(conversation.messages[1].id, 'old-assistant'); assert.equal(conversation.messages[1].metadata.turnId, user.metadata.turnId); assert.equal(conversation.messages[1].metadata.status, 'completed'); + assert.equal(conversation.messages[1].metadata.modelId, 'current-model'); + assert.ok(conversation.messages[1].metadata.createdAt! > 1); assert.equal(conversation.messages[1].parts.find((part) => part.type === 'text')?.text, 'New answer'); assert.equal(conversation.messages[1].metadata.error, undefined); assert.equal((await stores.runtime.get()).chat.pendingAttachments?.[0].id, 'pending-file'); + assert.deepEqual(await validateConversationMessages(conversation.messages), conversation.messages); +}); + +test('regenerating a completed reply preserves earlier history and removes later turns', async (t) => { + const { stores, runner, getConversation } = await setup(); + const previous = getConversation(); + const history = [previous.messages[0], withMessageStatus(previous.messages[1], 'completed')]; + const targetUser = createUserMessage('turn-2', 'Second question', []); + const target = withMessageStatus(createAssistantMessage('target', { turnId: 'turn-2' }), 'completed'); + target.parts.push({ type: 'text', text: 'Replace this answer' }); + const later = withMessageStatus(createAssistantMessage('later', { turnId: 'turn-3' }), 'completed'); + later.parts.push({ type: 'text', text: 'Later answer' }); + const conversation = { + ...previous, + messages: [...history, targetUser, target, createUserMessage('turn-3', 'Later question', []), later], + }; + await stores.conversations.update(previous.id, conversation); + await stores.runtime.setIn('chat.conversation', conversation); + const fetch = t.mock.method(globalThis, 'fetch', async () => response()); + + await runner.retry(target.id); + + assert.equal(fetch.mock.callCount(), 1); + const request = JSON.parse(fetch.mock.calls[0].arguments[1]?.body as string); + assert.deepEqual( + request.messages.map((message: { role: string }) => message.role), + ['system', 'user', 'assistant', 'user'], + ); + assert.match(JSON.stringify(request.messages), /Original question|Old partial answer/); + assert.match(JSON.stringify(request.messages), /Second question/); + assert.doesNotMatch(JSON.stringify(request.messages), /Replace this answer|Later question|Later answer/); + const messages = getConversation().messages; + assert.equal(messages.length, 4); + assert.deepEqual(messages.slice(0, 3), [...history, targetUser]); + assert.notEqual(messages[3].id, target.id); + assert.equal(messages[3].parts.find((part) => part.type === 'text')?.text, 'New answer'); +}); + +test('fork creates and selects an independent conversation through the chosen reply', async () => { + const { stores, runner, getConversation, getSelectedId } = await setup(); + const previous = getConversation(); + previous.messages[1].metadata.modelId = 'original-model'; + previous.messages[1].metadata.createdAt = 1234; + previous.messages[1].parts.push( + { type: 'reasoning', text: 'Original reasoning', state: 'done' }, + { + type: 'dynamic-tool', + toolCallId: 'read-1', + toolName: 'read', + state: 'output-available', + input: {}, + output: 'File content', + }, + ); + const later = withMessageStatus(createAssistantMessage('later', { turnId: 'turn-2' }), 'completed'); + const conversation = { + ...previous, + messages: [...previous.messages, createUserMessage('turn-2', 'Later question', []), later], + }; + await stores.conversations.update(previous.id, conversation); + await stores.runtime.setIn('chat.conversation', conversation); + + await runner.fork('old-assistant'); + + const fork = (await stores.runtime.get()).chat.conversation!; + assert.notEqual(fork.id, previous.id); + assert.equal(getSelectedId(), fork.id); + assert.deepEqual(await stores.conversations.get(fork.id), fork); + assert.deepEqual(fork.messages, previous.messages); + assert.equal(getConversation().messages.length, 4); + assert.deepEqual((await stores.runtime.get()).chat.pendingAttachments, []); + const attachmentPart = fork.messages[0].parts.find((part) => part.type === 'data-attachments')!; + attachmentPart.data[0].content = 'Changed in fork'; + assert.equal( + getConversation().messages[0].parts.find((part) => part.type === 'data-attachments')!.data[0].content, + 'original file snapshot', + ); +}); + +test('fork preserves navigation that changes while its messages are being saved', async (t) => { + const { stores, runner, getConversation, getSelectedId } = await setup(); + const destination = { ...getConversation(), id: 'other-conversation' }; + const select = stores.conversations.select.bind(stores.conversations); + t.mock.method(stores.conversations, 'select', async (id: string | undefined) => { + await select(id); + await stores.runtime.setIn('chat.conversation', destination); + }); + + await runner.fork('old-assistant'); + + assert.equal(getSelectedId(), destination.id); + assert.deepEqual((await stores.runtime.get()).chat.conversation, destination); }); diff --git a/tests/__tests__/index.test.ts b/tests/__tests__/index.test.ts index bf9210dd5..b310039b3 100644 --- a/tests/__tests__/index.test.ts +++ b/tests/__tests__/index.test.ts @@ -48,13 +48,14 @@ it('should load successfully', async () => { expect(tab).toBe('[Preview] README.md'); // Title updated based on the repo expect(await page.title()).toMatch(/\[Preview\] README\.md . conwnet\/github1s . GitHub1s/); - await page.waitForTimeout(5000); - // README file will be rendered in an iframe - await page.$eval( - 'iframe.webview.ready[sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock allow-downloads"][src]', - (el: HTMLElement) => el.innerHTML, - ); + // Wait for the README content and its assets inside the nested webview frames. + const preview = page.frameLocator('iframe.webview.ready').frameLocator('iframe[title="[Preview] README.md"]'); + await preview.getByRole('heading', { name: 'GitHub1s', exact: true }).waitFor(); + await preview.locator('body').evaluate(async () => { + await document.fonts.ready; + await Promise.all(Array.from(document.images, (image) => image.decode())); + }); const image = await page.screenshot(); expect(image).toMatchImageSnapshot(matchImageSnapshotOptions);