diff --git a/artifacts/ochat-design-comparison-final.png b/artifacts/ochat-design-comparison-final.png new file mode 100644 index 0000000..0ffb5c8 Binary files /dev/null and b/artifacts/ochat-design-comparison-final.png differ diff --git a/artifacts/ochat-live-edit-review-final.jpeg b/artifacts/ochat-live-edit-review-final.jpeg new file mode 100644 index 0000000..fc49455 Binary files /dev/null and b/artifacts/ochat-live-edit-review-final.jpeg differ diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..d335c57 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,40 @@ +# OChat design QA + +- Source visual truth: `/Users/raniendu/.codex/generated_images/019f6963-eac6-7113-9e56-3efabf79e590/exec-c00f0dff-120c-43cf-9a99-6d04edf5fb6e.png` +- Implementation screenshot: `/Users/raniendu/PycharmProjects/OChat/artifacts/ochat-live-edit-review-final.jpeg` +- Side-by-side comparison: `/Users/raniendu/PycharmProjects/OChat/artifacts/ochat-design-comparison-final.png` +- Viewport: Obsidian desktop window at 1505 × 768; OChat rendered in the right sidebar. +- State: dark theme, active Markdown note, completed local-model edit request, pending Markdown diff review, composer pinned, Ask mode selected. + +## Full-view comparison evidence + +The source and implementation were normalized to the same 744 px comparison height and placed in one side-by-side image. The implementation preserves the source hierarchy: compact product header, persistent context scope, flat transcript rows, edit-review card, anchored composer, and bottom connection status. The production sidebar is wider than the generated reference because Obsidian sidebars are user-resizable; the layout expands without changing hierarchy or losing controls. + +## Focused-region comparison evidence + +A separate crop was not needed because the normalized comparison keeps the context rows, message metadata, diff text, review actions, composer controls, and status labels readable in one image. The most fidelity-sensitive region—the diff review and its actions—is fully visible in the final comparison. + +## Required fidelity surfaces + +- Fonts and typography: Uses Obsidian's interface, text, and monospace tokens so it matches the host app while retaining the source's compact hierarchy and readable code treatment. +- Spacing and layout rhythm: Compact toolbar and context rows, flat message separators, restrained card radius, and pinned composer match the source. The wider live sidebar produces intentional extra horizontal space. +- Colors and visual tokens: Dark charcoal surfaces, muted borders, green connection state, semantic red/green diff colors, and accessible foreground contrast match the source. The primary accent follows the user's current Obsidian theme (purple in the capture) instead of hard-coding the source's blue. +- Image quality and asset fidelity: The design contains no raster content. All visible interface icons use Obsidian's native icon system; no CSS drawings, emoji stand-ins, or placeholder assets are present. +- Copy and content: Product copy is concise and user-facing. The quick edit action now shows `Review the active note and propose useful edits.` rather than internal patch-format instructions. + +## Findings + +No actionable P0, P1, or P2 differences remain. + +## Comparison history + +1. Initial live comparison found that a long patch could push Discard and Apply below the visible review region, and the quick edit prompt exposed JSON-format instructions in the transcript. +2. The diff region was capped at 210 px with internal scrolling, keeping both review actions visible. The quick edit prompt was reduced to natural product copy. +3. The plugin was rebuilt, reinstalled, reloaded, and exercised against the local model. Post-fix evidence shows the natural prompt, compact red/green diff, visible Discard and Apply actions, pinned composer, and ready connection status. + +## Follow-up polish + +- P3: At unusually wide sidebar sizes, the transcript has more horizontal whitespace than the narrow reference. This is acceptable responsive behavior and retains the same reading order. +- P3: The primary accent inherits the active Obsidian theme rather than forcing VS Code blue; this is intentional host integration. + +final result: passed diff --git a/src/view-data.ts b/src/view-data.ts new file mode 100644 index 0000000..4a37f4c --- /dev/null +++ b/src/view-data.ts @@ -0,0 +1,27 @@ +import type { ProviderKind } from './types'; + +export type ViewStatusKind = 'is-ready' | 'is-error' | 'is-busy'; + +export function getProviderLabel(provider: ProviderKind): string { + return provider === 'ollama' ? 'Ollama' : 'OpenAI compatible'; +} + +export function getEndpointLabel(baseUrl: string): string { + try { + const endpoint = new URL(baseUrl); + return endpoint.host || baseUrl; + } catch { + return baseUrl; + } +} + +export function getViewStatusKind(message: string): ViewStatusKind { + const status = message.toLowerCase(); + if (status.includes('fail') || status.includes('error') || status.includes('invalid')) { + return 'is-error'; + } + if (status.startsWith('ready') || status.startsWith('success') || status.startsWith('applied')) { + return 'is-ready'; + } + return 'is-busy'; +} diff --git a/src/view.ts b/src/view.ts index 711d4e4..ee83e51 100644 --- a/src/view.ts +++ b/src/view.ts @@ -9,6 +9,7 @@ import { resolveSubmitMode } from './modes'; import { parsePatchProposals } from './patches'; import type { ChatMessage, ComposerMode, PatchProposal } from './types'; import type { SubmitMode } from './modes'; +import { getEndpointLabel, getProviderLabel, getViewStatusKind } from './view-data'; export class OChatView extends ItemView { private readonly plugin: OChatPlugin; @@ -21,6 +22,8 @@ export class OChatView extends ItemView { private contextStripEl: HTMLElement | null = null; private mentionSuggestionsEl: HTMLElement | null = null; private testResultEl: HTMLElement | null = null; + private messageTimes: number[] = []; + private statusMessage = 'Ready'; private isAwaitingResponse = false; private onboardingProbeStarted = false; private contextOpen = false; @@ -60,6 +63,7 @@ export class OChatView extends ItemView { clearConversation(): void { this.history = []; + this.messageTimes = []; this.pendingPatches = []; this.render(); } @@ -69,14 +73,7 @@ export class OChatView extends ItemView { } async editActiveNote(): Promise { - await this.submitPrompt( - [ - 'Review the active Markdown note and propose useful edits.', - 'Return only JSON in this exact shape:', - '{"patches":[{"path":"path/to/note.md","original":"exact text","replacement":"new text","rationale":"why"}]}' - ].join(' '), - 'edit' - ); + await this.submitPrompt('Review the active note and propose useful edits.', 'edit'); } private render(): void { @@ -84,23 +81,23 @@ export class OChatView extends ItemView { contentEl.empty(); contentEl.addClass('ochat-view'); + this.renderHeader(contentEl); + + this.contextStripEl = contentEl.createDiv({ cls: 'ochat-context' }); + this.renderContextStrip(); + + this.setupEl = contentEl.createDiv({ cls: 'ochat-setup' }); + this.renderComposerSetup(); + this.transcriptEl = contentEl.createDiv({ cls: 'ochat-transcript' }); this.renderTranscript(); const composer = contentEl.createDiv({ cls: 'ochat-composer' }); - this.statusEl = composer.createDiv({ cls: 'ochat-status' }); - this.renderEndpointStatus(); - - this.setupEl = composer.createDiv({ cls: 'ochat-setup' }); - this.renderComposerSetup(); - - this.contextStripEl = composer.createDiv({ cls: 'ochat-context-strip' }); - this.renderContextStrip(); - this.promptEl = composer.createEl('textarea', { cls: 'ochat-prompt', attr: { - placeholder: 'Ask about this note, or type @ to attach more context' + placeholder: 'Ask about your notes…', + 'aria-label': 'Ask a question' } }); this.promptEl.addEventListener('keydown', (event) => { @@ -125,7 +122,16 @@ export class OChatView extends ItemView { const footer = composer.createDiv({ cls: 'ochat-composer-footer' }); const controls = footer.createDiv({ cls: 'ochat-composer-controls' }); - this.createIconButton(controls, 'plus', 'Add context files', () => { + this.createIconButton(controls, 'at-sign', 'Mention a note', () => { + if (this.promptEl) { + const start = this.promptEl.selectionStart; + const end = this.promptEl.selectionEnd; + this.promptEl.setRangeText('@', start, end, 'end'); + this.promptEl.focus(); + this.renderMentionSuggestions(); + } + }); + this.createIconButton(controls, 'paperclip', 'Attach context files', () => { this.contextOpen = toggleToolsPanel(this.contextOpen); this.settingsOpen = false; this.renderComposerSetup(); @@ -136,17 +142,34 @@ export class OChatView extends ItemView { }); const actions = footer.createDiv({ cls: 'ochat-composer-actions' }); + this.createIconButton(actions, 'send', 'Send', () => { + void this.submitFromComposer(); + }, 'ochat-send-button mod-cta'); + + this.statusEl = contentEl.createDiv({ cls: 'ochat-status' }); + this.renderEndpointStatus(); + } + + private renderHeader(containerEl: HTMLElement): void { + const header = containerEl.createDiv({ cls: 'ochat-header' }); + const brand = header.createDiv({ cls: 'ochat-brand' }); + brand.createSpan({ cls: 'ochat-brand-name', text: 'OChat' }); + brand.createSpan({ cls: 'ochat-connection-dot', attr: { 'aria-label': 'Local model connection' } }); + + const actions = header.createDiv({ cls: 'ochat-header-actions' }); + this.createIconButton(actions, 'square-pen', 'New chat', () => { + this.clearConversation(); + }); + this.createIconButton(actions, 'files', 'Attach context', () => { + this.contextOpen = toggleToolsPanel(this.contextOpen); + this.settingsOpen = false; + this.renderComposerSetup(); + }); this.createIconButton(actions, 'settings', 'Settings', () => { this.settingsOpen = !this.settingsOpen; this.contextOpen = false; this.renderComposerSetup(); }); - this.createIconButton(actions, 'trash-2', 'Clear conversation', () => { - this.clearConversation(); - }); - this.createIconButton(actions, 'send', 'Send', () => { - void this.submitFromComposer(); - }, 'ochat-send-button mod-cta'); } private createIconButton( @@ -176,27 +199,60 @@ export class OChatView extends ItemView { this.transcriptEl.empty(); if (this.history.length === 0 && this.pendingPatches.length === 0) { - this.transcriptEl.createDiv({ - cls: 'ochat-empty', - text: 'Ask a question, summarize the active note, or request Markdown edits.' + const empty = this.transcriptEl.createDiv({ cls: 'ochat-empty' }); + const icon = empty.createDiv({ cls: 'ochat-empty-icon' }); + setIcon(icon, 'bot'); + empty.createDiv({ cls: 'ochat-empty-title', text: 'Ask OChat' }); + empty.createDiv({ + cls: 'ochat-empty-copy', + text: 'Use the active note, selected text, and attached files to answer questions or prepare safe Markdown edits.' + }); + const suggestions = empty.createDiv({ cls: 'ochat-empty-actions' }); + suggestions.createEl('button', { text: 'Summarize active note' }, (button) => { + button.addEventListener('click', () => { + void this.askActiveNote(); + }); + }); + suggestions.createEl('button', { text: 'Suggest edits' }, (button) => { + button.addEventListener('click', () => { + void this.editActiveNote(); + }); }); return; } - for (const message of this.history) { + for (const [index, message] of this.history.entries()) { const messageEl = this.transcriptEl.createDiv({ cls: `ochat-message ochat-message-${message.role}` }); - messageEl.createDiv({ cls: 'ochat-message-role', text: message.role }); - this.renderMessageContent(messageEl, message); + const avatar = messageEl.createDiv({ cls: 'ochat-message-avatar' }); + setIcon(avatar, message.role === 'assistant' ? 'bot' : 'user-round'); + const body = messageEl.createDiv({ cls: 'ochat-message-body' }); + const header = body.createDiv({ cls: 'ochat-message-header' }); + header.createSpan({ + cls: 'ochat-message-role', + text: message.role === 'assistant' ? 'OChat' : 'You' + }); + header.createEl('time', { + cls: 'ochat-message-time', + text: this.formatMessageTime(this.messageTimes[index]) + }); + this.renderMessageContent(body, message); } if (this.isAwaitingResponse) { const thinkingEl = this.transcriptEl.createDiv({ cls: 'ochat-message ochat-message-assistant ochat-message-thinking' }); - thinkingEl.createDiv({ cls: 'ochat-message-role', text: 'assistant' }); - thinkingEl.createDiv({ cls: 'ochat-thinking-live', text: 'thinking...' }); + const avatar = thinkingEl.createDiv({ cls: 'ochat-message-avatar' }); + setIcon(avatar, 'bot'); + const body = thinkingEl.createDiv({ cls: 'ochat-message-body' }); + const header = body.createDiv({ cls: 'ochat-message-header' }); + header.createSpan({ cls: 'ochat-message-role', text: 'OChat' }); + header.createEl('time', { cls: 'ochat-message-time', text: this.formatMessageTime(Date.now()) }); + const live = body.createDiv({ cls: 'ochat-thinking-live' }); + live.createSpan({ cls: 'ochat-thinking-pulse' }); + live.createSpan({ text: 'Thinking…' }); } if (this.pendingPatches.length > 0) { @@ -238,34 +294,62 @@ export class OChatView extends ItemView { } this.contextStripEl.empty(); + const heading = this.contextStripEl.createDiv({ cls: 'ochat-context-heading' }); + heading.createSpan({ text: 'Context' }); + if (this.plugin.settings.maxVaultResults > 0) { + heading.createSpan({ cls: 'ochat-context-search-status', text: 'Vault search on' }); + } + + const card = this.contextStripEl.createDiv({ cls: 'ochat-context-card' }); const activePath = this.plugin.getActiveMarkdownPath(); if (activePath) { - this.contextStripEl.createSpan({ - cls: 'ochat-context-chip ochat-context-chip-active', - text: `Active: ${activePath}` - }); + const row = card.createDiv({ cls: 'ochat-context-row ochat-context-row-active' }); + const icon = row.createSpan({ cls: 'ochat-context-icon' }); + setIcon(icon, 'file-text'); + row.createSpan({ cls: 'ochat-context-path', text: activePath }); + const status = row.createSpan({ cls: 'ochat-context-active-status' }); + status.createSpan({ text: 'Active' }); + status.createSpan({ cls: 'ochat-context-active-dot' }); + } else { + const row = card.createDiv({ cls: 'ochat-context-row ochat-context-row-empty' }); + const icon = row.createSpan({ cls: 'ochat-context-icon' }); + setIcon(icon, 'file-x'); + row.createSpan({ cls: 'ochat-context-path', text: 'No active Markdown note' }); } for (const path of this.getAttachedContextPaths()) { - const chip = this.contextStripEl.createEl('button', { - cls: 'ochat-context-chip ochat-context-chip-removable', - attr: { title: `Remove ${path}` } + const row = card.createDiv({ cls: 'ochat-context-row' }); + const icon = row.createSpan({ cls: 'ochat-context-icon' }); + setIcon(icon, 'file-text'); + row.createSpan({ cls: 'ochat-context-path', text: path }); + const close = row.createEl('button', { + cls: 'ochat-context-remove ochat-icon-button', + attr: { title: `Remove ${path}`, 'aria-label': `Remove ${path}` } }); - chip.createSpan({ text: `@${path}` }); - const close = chip.createSpan({ cls: 'ochat-context-chip-x' }); setIcon(close, 'x'); - chip.addEventListener('click', () => { + close.addEventListener('click', () => { this.removeContextFile(path); }); } - if (this.plugin.settings.maxVaultResults > 0) { - this.contextStripEl.createSpan({ - cls: 'ochat-context-chip ochat-context-chip-muted', - text: 'Vault search' - }); - } + const add = card.createEl('button', { + cls: 'ochat-context-add', + attr: { title: 'Attach context files' } + }); + const addIcon = add.createSpan({ cls: 'ochat-context-icon' }); + setIcon(addIcon, 'plus'); + add.createSpan({ cls: 'ochat-context-path', text: 'Add context…' }); + add.createSpan({ cls: 'ochat-keycap', text: '@' }); + add.addEventListener('click', () => { + this.contextOpen = true; + this.settingsOpen = false; + this.renderComposerSetup(); + }); + } + + private formatMessageTime(timestamp = Date.now()): string { + return new Date(timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } private renderSettingsPanel(containerEl: HTMLElement): void { @@ -551,28 +635,50 @@ export class OChatView extends ItemView { } const reviewEl = this.transcriptEl.createDiv({ cls: 'ochat-review' }); - reviewEl.createDiv({ cls: 'ochat-review-title', text: 'Pending Markdown edits' }); + const reviewHeader = reviewEl.createDiv({ cls: 'ochat-review-header' }); + const reviewIcon = reviewHeader.createSpan({ cls: 'ochat-review-icon' }); + setIcon(reviewIcon, 'square-pen'); + const reviewHeading = reviewHeader.createDiv({ cls: 'ochat-review-heading' }); + reviewHeading.createDiv({ + cls: 'ochat-review-title', + text: `${patches.length} pending edit${patches.length === 1 ? '' : 's'}` + }); + reviewHeading.createDiv({ + cls: 'ochat-review-subtitle', + text: patches.length === 1 ? patches[0].path : `${patches.length} Markdown files` + }); for (const patch of patches) { const patchEl = reviewEl.createDiv({ cls: 'ochat-patch' }); - patchEl.createDiv({ cls: 'ochat-patch-path', text: patch.path }); + if (patches.length > 1) { + patchEl.createDiv({ cls: 'ochat-patch-path', text: patch.path }); + } patchEl.createDiv({ cls: 'ochat-patch-rationale', text: patch.rationale }); - patchEl.createEl('pre', { cls: 'ochat-patch-original', text: patch.original }); - patchEl.createEl('pre', { cls: 'ochat-patch-replacement', text: patch.replacement }); + const diff = patchEl.createDiv({ cls: 'ochat-diff', attr: { 'aria-label': `Proposed changes for ${patch.path}` } }); + for (const line of patch.original.split('\n')) { + const row = diff.createDiv({ cls: 'ochat-diff-line ochat-diff-line-removed' }); + row.createSpan({ cls: 'ochat-diff-prefix', text: '−' }); + row.createSpan({ cls: 'ochat-diff-text', text: line || ' ' }); + } + for (const line of patch.replacement.split('\n')) { + const row = diff.createDiv({ cls: 'ochat-diff-line ochat-diff-line-added' }); + row.createSpan({ cls: 'ochat-diff-prefix', text: '+' }); + row.createSpan({ cls: 'ochat-diff-text', text: line || ' ' }); + } } const actions = reviewEl.createDiv({ cls: 'ochat-actions' }); - actions.createEl('button', { text: 'Apply edits', cls: 'mod-cta' }, (button) => { - button.addEventListener('click', () => { - void this.applyPendingPatches(); - }); - }); actions.createEl('button', { text: 'Discard' }, (button) => { button.addEventListener('click', () => { this.pendingPatches = []; this.renderTranscript(); }); }); + actions.createEl('button', { text: 'Apply', cls: 'mod-cta' }, (button) => { + button.addEventListener('click', () => { + void this.applyPendingPatches(); + }); + }); } private renderEndpointStatus(): void { @@ -582,11 +688,20 @@ export class OChatView extends ItemView { this.statusEl.empty(); const endpoint = this.plugin.getEndpointClassification(); - this.statusEl.setText(`${this.plugin.settings.provider} - ${this.plugin.settings.baseUrl}`); + const connection = this.statusEl.createDiv({ cls: 'ochat-status-connection' }); + const connectionIcon = connection.createSpan({ cls: 'ochat-status-icon' }); + setIcon(connectionIcon, 'plug'); + connection.createSpan({ + text: `${getProviderLabel(this.plugin.settings.provider)} · ${getEndpointLabel(this.plugin.settings.baseUrl)}` + }); + + const state = this.statusEl.createDiv({ cls: `ochat-status-state ${getViewStatusKind(this.statusMessage)}` }); + state.createSpan({ cls: 'ochat-status-dot' }); + state.createSpan({ text: this.statusMessage }); if (endpoint.requiresAcknowledgement) { this.statusEl.createDiv({ - cls: 'ochat-warning', + cls: 'ochat-status-warning', text: endpoint.reason }); } @@ -619,6 +734,7 @@ export class OChatView extends ItemView { const messages = await this.plugin.buildMessages(requestPrompt, this.history, this.contextFilePaths); this.history.push({ role: 'user', content: prompt }); + this.messageTimes.push(Date.now()); this.isAwaitingResponse = true; this.renderTranscript(); this.setStatus('Waiting for model...'); @@ -630,6 +746,7 @@ export class OChatView extends ItemView { await this.handleEditAnswer(answer); } else { this.history.push({ role: 'assistant', ...parseAssistantResponse(answer) }); + this.messageTimes.push(Date.now()); } this.setStatus('Ready.'); @@ -649,6 +766,7 @@ export class OChatView extends ItemView { if (patches.length === 0) { this.history.push({ role: 'assistant', ...parsed }); + this.messageTimes.push(Date.now()); new Notice('The model did not return valid patch JSON.'); return; } @@ -660,6 +778,7 @@ export class OChatView extends ItemView { content: `Prepared ${patches.length} Markdown edit${patches.length === 1 ? '' : 's'} for review.`, thinking: parsed.thinking }); + this.messageTimes.push(Date.now()); return; } @@ -668,6 +787,7 @@ export class OChatView extends ItemView { role: 'assistant', content: results.map((result) => JSON.stringify(result)).join('\n') }); + this.messageTimes.push(Date.now()); } private async applyPendingPatches(): Promise { @@ -682,6 +802,7 @@ export class OChatView extends ItemView { role: 'assistant', content: results.map((result) => JSON.stringify(result)).join('\n') }); + this.messageTimes.push(Date.now()); this.renderTranscript(); new Notice('Applied reviewed edits.'); } catch (error) { @@ -746,7 +867,8 @@ export class OChatView extends ItemView { } private setStatus(message: string): void { - this.statusEl?.setText(message); + this.statusMessage = message; + this.renderEndpointStatus(); } private setTestResult(message: string, kind: 'pending' | 'success' | 'warning' | 'error'): void { diff --git a/styles.css b/styles.css index b07c1d1..84bc584 100644 --- a/styles.css +++ b/styles.css @@ -1,40 +1,247 @@ .ochat-view { + --ochat-border: var(--background-modifier-border); + --ochat-border-hover: var(--background-modifier-border-hover, var(--background-modifier-border)); + --ochat-panel: var(--background-secondary); + --ochat-panel-alt: var(--background-primary-alt, var(--background-secondary)); + --ochat-accent: var(--interactive-accent); + background: var(--background-primary); + color: var(--text-normal); display: flex; flex-direction: column; - gap: 10px; height: 100%; - padding: 10px; + min-height: 0; + overflow: hidden; + padding: 0 !important; } -.ochat-actions { +.ochat-header { + align-items: center; + border-bottom: 1px solid var(--ochat-border); display: flex; - flex-wrap: wrap; - gap: 8px; + flex: 0 0 44px; + justify-content: space-between; + padding: 0 10px 0 14px; } -.ochat-status { +.ochat-brand, +.ochat-header-actions, +.ochat-composer-controls, +.ochat-composer-actions, +.ochat-status-connection, +.ochat-status-state, +.ochat-context-active-status { + align-items: center; + display: flex; +} + +.ochat-brand { + gap: 8px; + min-width: 0; +} + +.ochat-brand-name { + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ochat-connection-dot, +.ochat-context-active-dot, +.ochat-status-dot, +.ochat-thinking-pulse { + background: var(--color-green); + border-radius: 50%; + display: inline-block; + height: 7px; + width: 7px; +} + +.ochat-header-actions { + gap: 2px; +} + +.ochat-icon-button, +.ochat-send-button, +.ochat-context-remove { + align-items: center; + background: transparent; + border: 0; + box-shadow: none; color: var(--text-muted); + display: inline-flex; + height: 28px; + justify-content: center; + min-height: 28px; + min-width: 28px; + padding: 4px; + width: 28px; +} + +.ochat-icon-button:hover, +.ochat-icon-button:focus-visible, +.ochat-context-remove:hover, +.ochat-context-remove:focus-visible { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.ochat-icon-button:focus-visible, +.ochat-context-remove:focus-visible, +.ochat-context-add:focus-visible, +.ochat-mode-toggle-button:focus-visible, +.ochat-send-button:focus-visible { + box-shadow: 0 0 0 1px var(--ochat-accent); + outline: 0; +} + +.ochat-context { + border-bottom: 1px solid var(--ochat-border); + flex: 0 0 auto; + padding: 12px 14px; +} + +.ochat-context-heading { + align-items: center; + color: var(--text-muted); + display: flex; font-size: var(--font-ui-smaller); - min-height: 18px; + font-weight: var(--font-semibold); + justify-content: space-between; + letter-spacing: 0.07em; + margin-bottom: 7px; + text-transform: uppercase; +} + +.ochat-context-search-status { + font-size: 9px; + font-weight: var(--font-medium); + letter-spacing: 0; + opacity: 0.8; + text-transform: none; +} + +.ochat-context-card { + background: var(--ochat-panel); + border: 1px solid var(--ochat-border); + border-radius: 4px; + overflow: hidden; +} + +.ochat-context-row, +.ochat-context-add { + align-items: center; + border-bottom: 1px solid var(--ochat-border); + box-sizing: border-box; + display: flex; + gap: 8px; + min-height: 34px; + padding: 4px 8px; + width: 100%; +} + +.ochat-context-row:last-child, +.ochat-context-add:last-child { + border-bottom: 0; +} + +.ochat-context-row-active { + background: var(--background-modifier-hover); +} + +.ochat-context-row-empty { + color: var(--text-muted); +} + +.ochat-context-icon { + align-items: center; + color: var(--text-muted); + display: inline-flex; + flex: 0 0 16px; + justify-content: center; +} + +.ochat-context-icon svg { + height: 16px; + width: 16px; +} + +.ochat-context-path { + flex: 1; + font-size: var(--font-ui-small); + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ochat-context-active-status { + color: var(--color-green); + font-size: var(--font-ui-smaller); + gap: 5px; +} + +.ochat-context-add { + background: transparent; + border-left: 0; + border-radius: 0; + border-right: 0; + border-top: 0; + box-shadow: none; + color: var(--text-muted); + justify-content: flex-start; +} + +.ochat-context-add:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.ochat-keycap { + border: 1px solid var(--ochat-border-hover); + border-radius: 3px; + color: var(--text-faint); + font-family: var(--font-monospace); + font-size: 10px; + line-height: 16px; + min-width: 18px; + padding: 0 4px; + text-align: center; } .ochat-setup { + background: var(--ochat-panel-alt); + border-bottom: 1px solid var(--ochat-border); display: flex; + flex: 0 0 auto; flex-direction: column; - gap: 8px; - padding-bottom: 4px; + gap: 9px; + max-height: 260px; + overflow: auto; + padding: 12px 14px; +} + +.ochat-setup:empty { + display: none; } .ochat-connection-title { color: var(--text-muted); font-size: var(--font-ui-smaller); font-weight: var(--font-semibold); + letter-spacing: 0.06em; text-transform: uppercase; } -.ochat-muted { +.ochat-muted, +.ochat-warning { color: var(--text-muted); - font-size: var(--font-ui-small); + font-size: var(--font-ui-smaller); +} + +.ochat-warning { + color: var(--text-warning); } .ochat-setting-row { @@ -49,59 +256,195 @@ font-weight: var(--font-medium); } -.ochat-model-picker, -.ochat-context-picker, +.ochat-endpoint-input, .ochat-context-search, -.ochat-endpoint-input { +.ochat-context-picker { max-width: 100%; + width: 100%; } -.ochat-warning { - color: var(--text-warning); - margin-top: 6px; +.ochat-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.ochat-setup > .ochat-actions { + justify-content: flex-start; +} + +.ochat-context-list, +.ochat-context-results { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ochat-context-list { + gap: 5px; +} + +.ochat-context-file { + align-items: center; + background: var(--background-primary); + border: 1px solid var(--ochat-border); + border-radius: 4px; + display: flex; + gap: 8px; + justify-content: space-between; + padding: 5px 7px; +} + +.ochat-context-file span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ochat-context-results { + max-height: 150px; + overflow: auto; +} + +.ochat-context-result, +.ochat-mention-suggestion { + background: transparent; + border: 0; + border-radius: 3px; + box-shadow: none; + justify-content: flex-start; + overflow: hidden; + padding: 5px 7px; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.ochat-context-result:hover, +.ochat-mention-suggestion:hover { + background: var(--background-modifier-hover); } .ochat-transcript { display: flex; - flex: 1; + flex: 1 1 auto; flex-direction: column; - gap: 10px; min-height: 0; overflow: auto; + overscroll-behavior: contain; } .ochat-empty { + align-items: center; color: var(--text-muted); - font-size: var(--font-ui-small); - padding: 12px 0; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + margin: auto; + max-width: 320px; + padding: 28px 20px; + text-align: center; } -.ochat-message, -.ochat-review { - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - padding: 10px; - width: 100%; +.ochat-empty-icon { + align-items: center; + background: var(--ochat-panel); + border: 1px solid var(--ochat-border); + border-radius: 50%; + color: var(--color-green); + display: flex; + height: 40px; + justify-content: center; + margin-bottom: 10px; + width: 40px; } -.ochat-message-role, -.ochat-review-title, -.ochat-patch-path { - color: var(--text-muted); - font-size: var(--font-ui-smaller); +.ochat-empty-title { + color: var(--text-normal); + font-size: var(--font-ui-medium); font-weight: var(--font-semibold); - margin-bottom: 6px; - text-transform: uppercase; + margin-bottom: 5px; +} + +.ochat-empty-copy { + font-size: var(--font-ui-smaller); + line-height: 1.45; +} + +.ochat-empty-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; + margin-top: 14px; +} + +.ochat-message { + border-bottom: 1px solid var(--ochat-border); + display: grid; + grid-template-columns: 32px minmax(0, 1fr); + padding: 16px 14px; +} + +.ochat-message-avatar { + align-items: center; + background: var(--ochat-panel); + border: 1px solid var(--ochat-border); + border-radius: 50%; + color: var(--text-muted); + display: flex; + height: 24px; + justify-content: center; + margin-top: -2px; + width: 24px; +} + +.ochat-message-assistant .ochat-message-avatar { + color: var(--color-green); +} + +.ochat-message-avatar svg { + height: 14px; + width: 14px; +} + +.ochat-message-body { + min-width: 0; +} + +.ochat-message-header { + align-items: baseline; + display: flex; + gap: 8px; + justify-content: space-between; + margin-bottom: 9px; +} + +.ochat-message-role { + color: var(--text-normal); + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); +} + +.ochat-message-time { + color: var(--text-faint); + font-size: var(--font-ui-smaller); + font-variant-numeric: tabular-nums; } .ochat-user-content { + font-size: var(--font-ui-small); + line-height: 1.5; white-space: pre-wrap; } .ochat-markdown { font-family: var(--font-text); - font-size: var(--font-ui-medium); + font-size: var(--font-ui-small); line-height: var(--line-height-normal); max-width: 100%; overflow-wrap: anywhere; @@ -116,11 +459,33 @@ margin-bottom: 0; } +.ochat-markdown h1, +.ochat-markdown h2, +.ochat-markdown h3 { + font-size: var(--font-ui-medium); + line-height: 1.35; + margin: 1em 0 0.45em; +} + +.ochat-markdown p, +.ochat-markdown ul, +.ochat-markdown ol { + margin-block: 0.55em; +} + +.ochat-markdown code { + background: var(--background-modifier-hover); + border: 1px solid var(--ochat-border); + border-radius: 3px; + font-size: 0.9em; + padding: 1px 4px; +} + .ochat-thinking { - border-bottom: 1px solid var(--background-modifier-border); + border: 0; color: var(--text-muted); - margin-bottom: 8px; - padding-bottom: 8px; + margin: 0 0 8px; + padding: 0; } .ochat-thinking summary { @@ -129,29 +494,339 @@ font-weight: var(--font-medium); } -.ochat-thinking-live { - color: var(--text-muted); - font-size: var(--font-ui-small); - font-style: italic; -} - -.ochat-thinking pre, -.ochat-patch pre { - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); +.ochat-thinking pre { + background: var(--ochat-panel); + border: 1px solid var(--ochat-border); border-radius: 4px; - margin: 6px 0; + font-size: var(--font-ui-smaller); + margin: 7px 0; max-width: 100%; overflow: auto; padding: 8px; white-space: pre-wrap; } +.ochat-thinking-live { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: var(--font-ui-small); + gap: 7px; +} + +.ochat-thinking-pulse { + animation: ochat-pulse 1.25s ease-in-out infinite; + background: var(--ochat-accent); +} + +@keyframes ochat-pulse { + 0%, + 100% { + opacity: 0.35; + } + 50% { + opacity: 1; + } +} + +.ochat-review { + background: var(--ochat-panel); + border: 1px solid var(--ochat-border-hover); + border-radius: 5px; + box-shadow: 0 4px 14px rgb(0 0 0 / 12%); + margin: 14px; + overflow: hidden; +} + +.ochat-review-header { + align-items: center; + display: flex; + gap: 10px; + padding: 10px 12px; +} + +.ochat-review-icon { + color: var(--ochat-accent); + display: inline-flex; +} + +.ochat-review-heading { + min-width: 0; +} + +.ochat-review-title { + color: var(--text-normal); + font-size: var(--font-ui-small); + font-weight: var(--font-semibold); +} + +.ochat-review-subtitle, +.ochat-patch-path { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + margin-top: 2px; +} + +.ochat-patch { + border-top: 1px solid var(--ochat-border); +} + +.ochat-patch-path, +.ochat-patch-rationale { + padding: 8px 12px 0; +} + +.ochat-patch-rationale { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + line-height: 1.4; +} + +.ochat-diff { + background: var(--background-primary); + font-family: var(--font-monospace); + font-size: var(--font-ui-smaller); + margin-top: 8px; + max-height: 210px; + overflow: auto; + padding: 5px 0; +} + +.ochat-diff-line { + display: grid; + grid-template-columns: 24px minmax(max-content, 1fr); + line-height: 1.55; + min-width: max-content; + padding: 0 9px; +} + +.ochat-diff-prefix { + user-select: none; +} + +.ochat-diff-text { + white-space: pre; +} + +.ochat-diff-line-removed { + background: rgba(var(--color-red-rgb), 0.18); + color: var(--text-error); +} + +.ochat-diff-line-added { + background: rgba(var(--color-green-rgb), 0.16); + color: var(--text-success); +} + +.ochat-review > .ochat-actions { + border-top: 1px solid var(--ochat-border); + justify-content: flex-end; + padding: 9px 12px; +} + +.ochat-composer { + background: var(--background-primary-alt, var(--background-primary)); + border: 1px solid var(--ochat-border-hover); + border-radius: 5px; + box-shadow: 0 2px 8px rgb(0 0 0 / 10%); + display: flex; + flex: 0 0 auto; + flex-direction: column; + margin: 10px 14px; + min-height: 104px; + padding: 8px; + position: relative; +} + +.ochat-composer:focus-within { + border-color: var(--ochat-accent); + box-shadow: 0 0 0 1px var(--ochat-accent); +} + +.ochat-prompt { + background: transparent; + border: 0; + box-shadow: none; + font-size: var(--font-ui-small); + line-height: 1.45; + min-height: 58px; + outline: none; + padding: 2px 4px 8px; + resize: vertical; + width: 100%; +} + +.ochat-prompt:focus { + box-shadow: none; +} + +.ochat-prompt::placeholder { + color: var(--text-faint); +} + +.ochat-mention-suggestions { + background: var(--background-primary); + border: 1px solid var(--ochat-border-hover); + border-radius: 4px; + bottom: calc(100% - 8px); + box-shadow: 0 8px 20px rgb(0 0 0 / 20%); + display: none; + flex-direction: column; + gap: 2px; + left: 0; + max-height: 170px; + overflow: auto; + padding: 4px; + position: absolute; + right: 0; + z-index: 10; +} + +.ochat-mention-suggestions.is-visible { + display: flex; +} + +.ochat-composer-footer { + align-items: center; + display: flex; + gap: 7px; + justify-content: space-between; +} + +.ochat-composer-controls { + flex: 1; + flex-wrap: wrap; + gap: 4px; + min-width: 0; +} + +.ochat-composer-actions { + flex: 0 0 auto; +} + +.ochat-model-picker { + font-size: var(--font-ui-smaller); + height: 28px; + max-width: 132px; + min-width: 92px; +} + +.ochat-mode-toggle { + background: var(--ochat-panel); + border: 1px solid var(--ochat-border); + border-radius: 4px; + display: inline-flex; + height: 28px; + overflow: hidden; +} + +.ochat-mode-toggle-button { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + min-width: 40px; + padding: 3px 7px; +} + +.ochat-mode-toggle-button:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.ochat-mode-toggle-button.is-active { + background: var(--ochat-accent); + color: var(--text-on-accent); +} + +.ochat-send-button { + background: var(--ochat-accent); + color: var(--text-on-accent); +} + +.ochat-send-button:hover { + background: var(--interactive-accent-hover); + color: var(--text-on-accent); +} + +.ochat-status { + align-items: center; + border-top: 1px solid var(--ochat-border); + color: var(--text-muted); + display: flex; + flex: 0 0 28px; + font-family: var(--font-monospace); + font-size: 10px; + gap: 8px; + justify-content: space-between; + min-width: 0; + padding: 0 12px; + position: relative; +} + +.ochat-status-connection { + gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ochat-status-icon { + display: inline-flex; + flex: 0 0 auto; +} + +.ochat-status-icon svg { + height: 12px; + width: 12px; +} + +.ochat-status-state { + flex: 0 0 auto; + gap: 6px; +} + +.ochat-status-state.is-ready { + color: var(--text-success); +} + +.ochat-status-state.is-error { + color: var(--text-error); +} + +.ochat-status-state.is-error .ochat-status-dot { + background: var(--color-red); +} + +.ochat-status-state.is-busy { + color: var(--text-accent); +} + +.ochat-status-state.is-busy .ochat-status-dot { + animation: ochat-pulse 1.25s ease-in-out infinite; + background: var(--ochat-accent); +} + +.ochat-status-warning { + background: var(--background-modifier-error); + color: var(--text-error); + font-family: var(--font-interface); + font-size: var(--font-ui-smaller); + left: 0; + padding: 6px 12px; + position: absolute; + right: 0; + transform: translateY(calc(-100% - 14px)); +} + .ochat-test-result { border-radius: 4px; - font-size: var(--font-ui-small); + font-size: var(--font-ui-smaller); min-height: 24px; - padding: 4px 6px; + padding: 5px 7px; } .ochat-test-result:empty { @@ -174,242 +849,37 @@ color: var(--text-error); } -.ochat-message-user { - border-color: var(--interactive-accent); -} - -.ochat-patch { - border-top: 1px solid var(--background-modifier-border); - margin-top: 10px; - padding-top: 10px; -} - -.ochat-patch-rationale { - color: var(--text-normal); - font-size: var(--font-ui-small); - margin-bottom: 8px; -} - -.ochat-patch-original { - color: var(--text-error); -} - -.ochat-patch-replacement { - color: var(--text-success); -} - -.ochat-context-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -.ochat-context-file { - align-items: center; - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - display: flex; - gap: 8px; - justify-content: space-between; - padding: 6px 8px; -} - -.ochat-context-file span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ochat-context-search { - width: 100%; -} - -.ochat-context-results { - display: flex; - flex-direction: column; - gap: 2px; - max-height: 180px; - overflow: auto; -} - -.ochat-context-result { - background: transparent; - border: 0; - box-shadow: none; - justify-content: flex-start; - overflow: hidden; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; -} - -.ochat-context-strip { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 6px; - min-height: 28px; -} - -.ochat-context-chip { - align-items: center; - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 999px; - color: var(--text-muted); - display: inline-flex; - font-size: var(--font-ui-smaller); - gap: 4px; - line-height: 1.2; - max-width: 100%; - min-height: 24px; - padding: 3px 8px; -} - -.ochat-context-chip span:first-child, -.ochat-context-chip-active { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ochat-context-chip-active { - background: var(--background-modifier-hover); - color: var(--text-normal); -} - -.ochat-context-chip-muted { - border-style: dashed; -} - -.ochat-context-chip-removable { - cursor: pointer; -} - -.ochat-context-chip-x { - align-items: center; - display: inline-flex; - height: 14px; - width: 14px; -} - -.ochat-mention-suggestions { - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - box-shadow: 0 4px 14px rgb(0 0 0 / 12%); - display: none; - flex-direction: column; - gap: 2px; - max-height: 160px; - overflow: auto; - padding: 4px; -} - -.ochat-mention-suggestions.is-visible { - display: flex; -} - -.ochat-mention-suggestion { - background: transparent; - border: 0; - box-shadow: none; - justify-content: flex-start; - overflow: hidden; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; -} - -.ochat-composer { - background: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 8px; - box-shadow: 0 1px 4px rgb(0 0 0 / 8%); - display: flex; - flex-direction: column; - gap: 6px; - padding: 10px; -} - -.ochat-prompt { - background: transparent; - border: 0; - box-shadow: none; - font-size: var(--font-ui-medium); - min-height: 96px; - outline: none; - resize: vertical; - width: 100%; -} - -.ochat-prompt:focus { - box-shadow: none; -} - -.ochat-composer-footer { - align-items: center; - border-top: 1px solid var(--background-modifier-border); - display: flex; - gap: 8px; - justify-content: space-between; - padding-top: 8px; -} - -.ochat-composer-controls, -.ochat-composer-actions { - align-items: center; - display: flex; - flex-wrap: wrap; - gap: 6px; +.ochat-ghost-button { min-width: 0; } -.ochat-model-picker { - max-width: 170px; +@media (max-width: 360px) { + .ochat-context { + padding-inline: 10px; + } + + .ochat-message { + grid-template-columns: 28px minmax(0, 1fr); + padding-inline: 10px; + } + + .ochat-composer { + margin-inline: 10px; + } + + .ochat-model-picker { + max-width: 110px; + } + + .ochat-mode-toggle-button { + min-width: 36px; + padding-inline: 5px; + } } -.ochat-mode-toggle { - background: var(--background-secondary); - border: 1px solid var(--background-modifier-border); - border-radius: 6px; - display: inline-flex; - overflow: hidden; -} - -.ochat-mode-toggle-button { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - color: var(--text-muted); - font-size: var(--font-ui-smaller); - min-width: 44px; - padding: 4px 8px; -} - -.ochat-mode-toggle-button.is-active { - background: var(--interactive-accent); - color: var(--text-on-accent); -} - -.ochat-icon-button, -.ochat-ghost-button, -.ochat-send-button { - min-width: 0; -} - -.ochat-icon-button, -.ochat-send-button { - align-items: center; - display: inline-flex; - justify-content: center; - min-height: 28px; - min-width: 28px; - padding: 4px; +@media (prefers-reduced-motion: reduce) { + .ochat-thinking-pulse, + .ochat-status-state.is-busy .ochat-status-dot { + animation: none; + } } diff --git a/tests/view-data.test.ts b/tests/view-data.test.ts new file mode 100644 index 0000000..19625ec --- /dev/null +++ b/tests/view-data.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'vitest'; + +import { getEndpointLabel, getProviderLabel, getViewStatusKind } from '../src/view-data'; + +describe('view labels', () => { + test('uses compact provider and endpoint labels', () => { + expect(getProviderLabel('ollama')).toBe('Ollama'); + expect(getProviderLabel('openai-compatible')).toBe('OpenAI compatible'); + expect(getEndpointLabel('http://localhost:11434')).toBe('localhost:11434'); + }); + + test('preserves invalid endpoints so the user can diagnose them', () => { + expect(getEndpointLabel('not a url')).toBe('not a url'); + }); + + test('maps status copy to visible semantic states', () => { + expect(getViewStatusKind('Ready')).toBe('is-ready'); + expect(getViewStatusKind('Success. Found 2 models.')).toBe('is-ready'); + expect(getViewStatusKind('Failed. Connection refused.')).toBe('is-error'); + expect(getViewStatusKind('Waiting for model...')).toBe('is-busy'); + }); +});