diff --git a/extensions/github1s-ai/assets/chat.css b/extensions/github1s-ai/assets/chat.css index dc95f14cf..6d1f7c750 100644 --- a/extensions/github1s-ai/assets/chat.css +++ b/extensions/github1s-ai/assets/chat.css @@ -178,6 +178,8 @@ summary:focus-visible, .settings-card-actions, .settings-meta, .history-row, +.history-actions, +.history-title-editor, .history-confirm { display: flex; align-items: center; @@ -235,7 +237,7 @@ summary:focus-visible, box-sizing: border-box; display: flex; min-width: 0; - align-items: flex-start; + align-items: center; gap: 6px; padding: 8px; border: 1px solid var(--vscode-widget-border, transparent); @@ -475,7 +477,8 @@ summary:focus-visible, margin: 0; } -.settings-control { +.settings-control, +.history-title-input { width: 100%; min-width: 0; padding: 6px 7px; @@ -485,7 +488,8 @@ summary:focus-visible, border-radius: 3px; } -.settings-control:focus-visible { +.settings-control:focus-visible, +.history-title-input:focus-visible { border-color: var(--vscode-focusBorder); outline: none; } @@ -554,9 +558,11 @@ summary:focus-visible, } .history-row { + box-sizing: border-box; min-width: 0; + height: 46px; gap: 4px; - padding: 6px 8px; + padding: 5px 8px; border: 1px solid transparent; border-radius: 4px; } @@ -591,6 +597,17 @@ summary:focus-visible, white-space: nowrap; } +.history-title-editor { + min-width: 0; + flex: 1; + gap: 4px; +} + +.history-title-input { + height: 24px; + padding: 2px 6px; +} + .history-empty { margin: 12px 0; color: var(--vscode-descriptionForeground); @@ -617,14 +634,16 @@ summary:focus-visible, text-overflow: ellipsis; } -.history-delete { - opacity: 0; +.history-actions { + display: none; + flex: none; + gap: 2px; } -.history-row:hover .history-delete, -.history-row:focus-within .history-delete, -.history-row-active .history-delete { - opacity: 1; +.history-row:hover .history-actions, +.history-row:focus-within .history-actions, +.history-row-active .history-actions { + display: flex; } .ghost-button.history-confirm-delete { diff --git a/extensions/github1s-ai/src/common/protocol.ts b/extensions/github1s-ai/src/common/protocol.ts index e176d494d..029a4d3df 100644 --- a/extensions/github1s-ai/src/common/protocol.ts +++ b/extensions/github1s-ai/src/common/protocol.ts @@ -1,6 +1,6 @@ import { isPlainObject } from 'lodash-es'; -import { exactString, hasExactKeys } from '@/helpers/validate'; +import { exactString, hasExactKeys, isNonBlankString, isNonEmptyString } from '@/helpers/validate'; import { isContextAttachmentAction, @@ -54,6 +54,7 @@ type AllViewEvents = | { type: 'chat.setIncludeRecentFiles'; enabled: boolean } | { type: 'chat.cancel' } | { type: 'history.selectConversation'; id: string } + | { type: 'history.renameConversation'; id: string; title: string } | { type: 'history.deleteConversation'; id: string } | { type: 'markdown.highlight'; requestId: string; languageId: string; source: string } | { type: 'settings.selectModelConfig'; id: string } @@ -90,22 +91,25 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => { return hasExactKeys(event, ['type']) ? { type: event.type } : undefined; case 'app.openFile': - return exactString(event, 'source') ? { type: event.type, source: event.source as string } : undefined; + return exactString(event, 'source') ? { type: event.type, source: event.source } : undefined; case 'chat.send': - return hasExactKeys(event, ['type', 'text']) && typeof event.text === 'string' - ? { type: event.type, text: event.text } - : undefined; + return exactString(event, 'text') ? { type: event.type, text: event.text } : undefined; case 'chat.setEditingMessageId': if (hasExactKeys(event, ['type'])) return { type: event.type }; - return exactString(event, 'id') ? { type: event.type, id: event.id as string } : undefined; + return exactString(event, 'id') ? { type: event.type, id: event.id } : undefined; case 'chat.setIncludeRecentFiles': return hasExactKeys(event, ['type', 'enabled']) && typeof event.enabled === 'boolean' ? { type: event.type, enabled: event.enabled } : undefined; + case 'history.renameConversation': + return hasExactKeys(event, ['type', 'id', 'title']) && isNonEmptyString(event.id) && isNonBlankString(event.title) + ? { type: event.type, id: event.id, title: event.title } + : undefined; + case 'chat.retry': case 'chat.fork': case 'chat.removeContextAttachment': @@ -113,7 +117,7 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => { case 'history.deleteConversation': case 'settings.selectModelConfig': case 'settings.deleteModelConfig': - return exactString(event, 'id') ? { type: event.type, id: event.id as string } : undefined; + return exactString(event, 'id') ? { type: event.type, id: event.id } : undefined; case 'chat.runQuickAction': return exactString(event, 'action') && isQuickAction(event.action) @@ -133,10 +137,8 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => { case 'markdown.highlight': if ( !hasExactKeys(event, ['type', 'requestId', 'languageId', 'source']) || - typeof event.requestId !== 'string' || - event.requestId.length === 0 || - typeof event.languageId !== 'string' || - event.languageId.length === 0 || + !isNonEmptyString(event.requestId) || + !isNonEmptyString(event.languageId) || typeof event.source !== 'string' || event.source.length > MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH ) { @@ -158,7 +160,7 @@ export const parseViewEvent = (value: unknown): ViewEvent | undefined => { ? { type: event.type, config: event.config } : undefined; case 'settings.saveMcpConfig': - return exactString(event, 'json') ? { type: event.type, json: event.json as string } : undefined; + return exactString(event, 'json') ? { type: event.type, json: event.json } : undefined; default: return undefined; } diff --git a/extensions/github1s-ai/src/controllers/history.ts b/extensions/github1s-ai/src/controllers/history.ts index cc40eb417..4afc8379d 100644 --- a/extensions/github1s-ai/src/controllers/history.ts +++ b/extensions/github1s-ai/src/controllers/history.ts @@ -44,6 +44,27 @@ export class HistoryController extends Controller { }); } + @Controller.handler('history.renameConversation') + async handleRenameConversation(event: ViewEvent<'history.renameConversation'>): Promise { + const title = event.title.trim(); + if (!title) return; + try { + await this.stores.conversations.update(event.id, { title }); + } catch (error) { + const detail = error instanceof Error ? error.message : 'Unknown error.'; + await this.stores.runtime.setIn('history.notice', { + severity: 'error', + message: `Unable to rename the conversation. ${detail}`, + }); + return; + } + const runtime = await this.stores.runtime.get(); + if (runtime.chat.conversation?.id === event.id) { + await this.stores.runtime.setIn('chat.conversation.title', title); + } + await this.stores.runtime.setIn('history.notice', undefined); + } + @Controller.handler('history.deleteConversation') async handleDeleteConversation(event: ViewEvent<'history.deleteConversation'>): Promise { const selected = (await this.stores.runtime.get()).chat.conversation?.id === event.id; diff --git a/extensions/github1s-ai/src/controllers/runner.ts b/extensions/github1s-ai/src/controllers/runner.ts index 3ffbdba2f..36e765c1d 100644 --- a/extensions/github1s-ai/src/controllers/runner.ts +++ b/extensions/github1s-ai/src/controllers/runner.ts @@ -321,7 +321,7 @@ export class ConversationRunner { private async setCurrentConversation(conversation: Conversation, signal?: AbortSignal): Promise { const runtime = await this.stores.runtime.get(); if (runtime.chat.conversation?.id !== conversation.id || signal?.aborted) return; - await this.stores.runtime.setIn('chat.conversation', conversation); + await this.stores.runtime.setIn('chat.conversation', { ...conversation, title: runtime.chat.conversation.title }); } private async withPreparation( diff --git a/extensions/github1s-ai/src/helpers/validate.ts b/extensions/github1s-ai/src/helpers/validate.ts index b72d426c5..41a8d8eb2 100644 --- a/extensions/github1s-ai/src/helpers/validate.ts +++ b/extensions/github1s-ai/src/helpers/validate.ts @@ -1,5 +1,12 @@ -export const exactString = (value: Record, key: string): boolean => +export const exactString = ( + value: Record, + key: Key, +): value is Record & Record => hasExactKeys(value, ['type', key]) && typeof value[key] === 'string'; export const hasExactKeys = (value: Record, keys: readonly string[]): boolean => Object.keys(value).length === keys.length && keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)); + +export const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0; + +export const isNonBlankString = (value: unknown): value is string => isNonEmptyString(value) && value.trim().length > 0; diff --git a/extensions/github1s-ai/src/webview/components/Composer.ts b/extensions/github1s-ai/src/webview/components/Composer.ts index f2f6946f7..de264f68f 100644 --- a/extensions/github1s-ai/src/webview/components/Composer.ts +++ b/extensions/github1s-ai/src/webview/components/Composer.ts @@ -98,6 +98,14 @@ export const Composer = ({ state, active, post, message: editedMessage }: Compos }; const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' && event.altKey && !event.isComposing) { + event.preventDefault(); + const input = event.currentTarget as HTMLTextAreaElement; + // Use a native editing command to preserve undo history. + document.execCommand('insertText', false, '\n'); + setDraft(input.value); + return; + } if (!shouldSubmitComposer(event)) return; event.preventDefault(); submit(); diff --git a/extensions/github1s-ai/src/webview/components/HistoryPage.ts b/extensions/github1s-ai/src/webview/components/HistoryPage.ts index fcd04f8c0..9e3403ac3 100644 --- a/extensions/github1s-ai/src/webview/components/HistoryPage.ts +++ b/extensions/github1s-ai/src/webview/components/HistoryPage.ts @@ -1,5 +1,5 @@ import { html } from 'htm/preact'; -import { useEffect, useState } from 'preact/hooks'; +import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; import type { ViewEvent, ViewState } from '@/common/protocol'; @@ -15,7 +15,7 @@ interface HistoryPageProps { const RELATIVE_TIME_REFRESH_INTERVAL_MS = 60_000; export const HistoryPage = ({ state, post }: HistoryPageProps) => { - const [confirmingId, setConfirmingId] = useState(); + const [rowAction, setRowAction] = useState<{ id: string; type: 'edit' | 'delete' }>(); const [now, setNow] = useState(Date.now()); useEffect(() => { @@ -35,53 +35,144 @@ export const HistoryPage = ({ state, post }: HistoryPageProps) => { ${conversations.map((conversation) => { const title = conversation.title; const isActive = conversation.id === state.runtime.chat.conversation?.id; + const action = rowAction?.id === conversation.id ? rowAction.type : undefined; const time = formatConversationTime(conversation.updatedAt, now); const usage = presentConversationUsage(conversation.usage); + const editLabel = `Edit ${title}`; const deleteLabel = `Delete ${title}`; return html`
- - ${confirmingId === conversation.id - ? html`
- - -
` + ${action === 'edit' + ? html`<${HistoryTitleEditor} + title=${title} + onSave=${(title: string) => + post({ type: 'history.renameConversation', id: conversation.id, title })} + onCancel=${() => setRowAction(undefined)} + />` : html``} + ${action === 'edit' + ? null + : action === 'delete' + ? html`
+ + +
` + : html`
+ + +
`}
`; })} `} `; }; + +interface HistoryTitleEditorProps { + title: string; + onSave: (title: string) => void; + onCancel: () => void; +} + +const HistoryTitleEditor = ({ title, onSave, onCancel }: HistoryTitleEditorProps) => { + const [draft, setDraft] = useState(title); + const [submittedTitle, setSubmittedTitle] = useState(); + const input = useRef(null); + const nextTitle = draft.trim(); + + useLayoutEffect(() => { + input.current?.focus(); + input.current?.select(); + }, []); + + useEffect(() => { + if (submittedTitle !== title) return; + setSubmittedTitle(undefined); + if (nextTitle === title) onCancel(); + }, [title, submittedTitle, nextTitle, onCancel]); + + return html`
{ + event.preventDefault(); + if (!nextTitle) return; + if (nextTitle === title && submittedTitle === undefined) { + onCancel(); + return; + } + setSubmittedTitle(nextTitle); + onSave(nextTitle); + }} + onKeyDown=${(event: KeyboardEvent) => { + if (event.isComposing || event.keyCode === 229) { + if (event.key === 'Enter') event.preventDefault(); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + onCancel(); + } + }} + > + setDraft((event.currentTarget as HTMLInputElement).value)} + /> +
+ + +
+
`; +}; diff --git a/extensions/github1s-ai/test/protocol.test.ts b/extensions/github1s-ai/test/protocol.test.ts index 94576fb2e..3e755bea0 100644 --- a/extensions/github1s-ai/test/protocol.test.ts +++ b/extensions/github1s-ai/test/protocol.test.ts @@ -38,6 +38,22 @@ test('view events reject malformed payloads, unknown actions and unexpected fiel } }); +test('conversation title updates require an ID and a nonblank title', () => { + const event = { type: 'history.renameConversation', id: 'conversation-1', title: ' A new title ' }; + assert.deepEqual(parseViewEvent(event), event); + for (const invalid of [ + { ...event, id: '' }, + { ...event, id: 123 }, + { ...event, title: '' }, + { ...event, title: ' \t\n ' }, + { ...event, title: 123 }, + { type: event.type, id: event.id }, + { ...event, extra: true }, + ]) { + assert.equal(parseViewEvent(invalid), undefined, JSON.stringify(invalid)); + } +}); + test('highlight requests enforce the source length limit and required fields', () => { const request = { type: 'markdown.highlight', @@ -46,7 +62,15 @@ test('highlight requests enforce the source length limit and required fields', ( source: 'x'.repeat(MAX_SYNTAX_HIGHLIGHT_SOURCE_LENGTH), }; assert.deepEqual(parseViewEvent(request), request); - assert.equal(parseViewEvent({ ...request, source: `${request.source}x` }), undefined); - assert.equal(parseViewEvent({ ...request, requestId: '' }), undefined); - assert.equal(parseViewEvent({ ...request, extra: true }), undefined); + for (const invalid of [ + { ...request, source: `${request.source}x` }, + { ...request, source: 123 }, + { ...request, requestId: '' }, + { ...request, requestId: 123 }, + { ...request, languageId: '' }, + { ...request, languageId: 123 }, + { ...request, extra: true }, + ]) { + assert.equal(parseViewEvent(invalid), undefined); + } });