Redesign OChat sidebar UX

This commit is contained in:
Raniendu Singh 2026-07-16 07:11:30 -07:00
parent a902715385
commit 6292d82d8b
7 changed files with 1027 additions and 346 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

40
design-qa.md Normal file
View file

@ -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

27
src/view-data.ts Normal file
View file

@ -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';
}

View file

@ -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<void> {
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<void> {
@ -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 {

1040
styles.css

File diff suppressed because it is too large Load diff

22
tests/view-data.test.ts Normal file
View file

@ -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');
});
});