diff --git a/UI_UX_REVIEW.md b/UI_UX_REVIEW.md
new file mode 100644
index 0000000..a0782ae
--- /dev/null
+++ b/UI_UX_REVIEW.md
@@ -0,0 +1,173 @@
+# Keystone UI/UX Review
+
+Comprehensive review of all renderer components, stores, styling configuration, and shared UI primitives.
+
+---
+
+## 1. CRITICAL USABILITY ISSUES
+
+### 1a. No markdown rendering for AI responses
+**File:** `src/renderer/features/conversation/MessageBubble.tsx:37`
+
+The assistant messages use `whitespace-pre-wrap` and render `message.content` as raw text. Since the AI generates markdown (headings, code blocks, lists), users see raw markdown syntax instead of formatted output. The document panel has a full `renderMarkdown` pipeline (`src/renderer/lib/markdown.ts`), but the chat does not use it.
+
+**Impact:** The primary interaction surface (chat) looks broken for any non-trivial AI response.
+
+### 1b. No loading state during project open
+**File:** `src/renderer/App.tsx:236-281`
+
+`handleSelectProject` makes 5+ sequential network calls (open, list threads, get each thread, list docs, get each doc). During this time there is zero visual feedback -- the user sees the previous project content or an empty state until everything resolves.
+
+**Impact:** Users will think the app froze or the click didn't register, especially with larger projects.
+
+### 1c. No error feedback visible to users
+**Files:** `src/renderer/App.tsx:88-98, :100-111, :175-193, :195-213, :215-233`
+
+All error handling in `handleCreateProject`, `handleNewThread`, `handleBranchThread`, `handleInquire`, and `handleRefine` silently `console.error` and swallow the failure. The user has no indication that their action failed.
+
+**Impact:** Users will repeat actions thinking they didn't click, or abandon the app thinking it's broken.
+
+---
+
+## 2. SIGNIFICANT UX ISSUES
+
+### 2a. Theme toggle exists in store but is not exposed in the UI
+**File:** `src/renderer/stores/uiStore.ts:8`
+
+The store defines `theme: 'light' | 'dark' | 'system'` and `setTheme`, but no component exposes this to the user. The Tailwind config uses `darkMode: 'class'`, and there is no code applying the `dark` class to the HTML element based on the store state. Dark mode is currently non-functional despite being wired up in the store.
+
+### 2b. Panel ratio in uiStore is disconnected from ResizablePanel
+**Files:** `src/renderer/stores/uiStore.ts:6`, `src/renderer/components/ui/ResizablePanel.tsx:18`
+
+`uiStore` stores `panelRatio` and `setPanelRatio`, but `ResizablePanel` manages its own local `useState(defaultRatio)`. Resizing is lost on navigation or re-render. The store value is never read or written by ResizablePanel.
+
+### 2c. Thread list shows no timestamps or context
+**File:** `src/renderer/features/conversation/ThreadListItem.tsx:9`
+
+`ThreadListItem` accepts `updatedAt` but never renders it. The `id` prop is also unused. Users with many threads have only the title to distinguish between them.
+
+### 2d. Document tabs show only type abbreviation, not titles
+**File:** `src/renderer/features/document/DocumentTabs.tsx:24`
+
+Tabs render `doc.type.toUpperCase()` (e.g., "PRD", "TDD", "ADR") but not the document title. When a project has multiple ADRs, all tabs display "ADR" identically, making them indistinguishable.
+
+### 2e. No confirmation before destructive actions
+
+There is no delete/archive functionality for threads, projects, or documents exposed in the UI. If delete were added later, no confirmation pattern exists.
+
+---
+
+## 3. LAYOUT & VISUAL ISSUES
+
+### 3a. Double sidebar creates a cramped workspace
+**Files:** `src/renderer/components/Sidebar.tsx:12`, `src/renderer/features/conversation/ConversationPanel.tsx:27`
+
+The project Sidebar (w-56, 224px) alongside the thread list (w-56, 224px) consumes 448px -- 35% of a 1280px display -- leaving only ~832px for the split conversation+document panels.
+
+### 3b. Message bubble max-width is relative to wrong container
+**File:** `src/renderer/features/conversation/MessageBubble.tsx:31`
+
+`max-w-[80%]` is relative to the parent flex row with padding. On narrow panes, bubbles become uncomfortably narrow. A fixed `max-w-prose` or `max-w-2xl` would be more predictable.
+
+### 3c. Document outline has no fixed width
+**File:** `src/renderer/features/document/DocumentOutline.tsx:13`
+
+The outline has no width constraint, causing layout shifts when switching between documents with different heading lengths.
+
+### 3d. Selection toolbar positioning can overflow viewport
+**File:** `src/renderer/features/document/MarkdownPreview.tsx:32`
+
+Toolbar position is computed without clamping. Selections near container edges cause the toolbar to render partially off-screen.
+
+### 3e. Resizable panel divider is too thin
+**File:** `src/renderer/components/ui/ResizablePanel.tsx:58`
+
+The `w-1` (4px) drag handle is at the lower end of comfortable click targets. Most applications use 6-8px with a wider hover zone.
+
+---
+
+## 4. ACCESSIBILITY ISSUES
+
+### 4a. Dialog lacks focus trap
+**File:** `src/renderer/components/ui/Dialog.tsx`
+
+The Dialog handles Escape to close but does not trap focus within the dialog or return focus to the trigger on close. Keyboard users can tab behind the modal.
+
+### 4b. No ARIA attributes on interactive elements
+- Sidebar toggle in `TitleBar.tsx:19-28` -- no `aria-label` or `aria-expanded`
+- Radio buttons in `ProviderCard.tsx:41-46` -- no proper label association
+- `ThreadListItem` buttons -- no `aria-current` for active state
+- `Dialog` component -- uses plain `
` instead of `role="dialog"` with `aria-modal`
+
+### 4c. SVG icons have no accessible text
+
+All inline SVGs (send button, settings gear, sidebar toggle, branch, inquire, refine) have no `aria-label` or `
` element.
+
+### 4d. Insufficient color contrast in subtle text
+
+`text-gray-400` and `text-gray-500` used for secondary text may not meet WCAG AA contrast requirements against white or dark backgrounds.
+
+---
+
+## 5. INTERACTION DESIGN GAPS
+
+### 5a. No keyboard shortcuts
+
+No keyboard shortcuts for common actions: new thread (Cmd+N), toggle sidebar (Cmd+B), send message (Cmd+Enter), switch document tabs, etc.
+
+### 5b. Message input doesn't indicate Shift+Enter for newlines
+**File:** `src/renderer/features/conversation/MessageInput.tsx:21-26`
+
+Enter to send and Shift+Enter for newlines is supported but not visually indicated.
+
+### 5c. No drag-and-drop or file attachment
+
+Users cannot drag in existing documents, images, or reference files into conversations.
+
+### 5d. Streaming indicator logic has a brief gap
+**File:** `src/renderer/features/conversation/ChatView.tsx:18`
+
+The bouncing-dots indicator appears only when `isStreaming && !streamingMessageId` -- the brief window before the first chunk. The transition from dots to cursor-in-bubble can feel jarring.
+
+### 5e. No search or filter in thread list
+
+Users with many conversation threads have no way to search or filter. Only "active" threads are shown with no way to view archived ones.
+
+---
+
+## 6. DESIGN SYSTEM OBSERVATIONS
+
+### 6a. Custom semantic colors defined but not used
+**File:** `tailwind.config.ts`
+
+Custom tokens `surface`, `panel`, and `accent` are defined but components use raw Tailwind colors directly (`bg-gray-100`, `bg-indigo-600`, etc.), making global theme changes require touching every file.
+
+### 6b. Inconsistent button patterns
+
+`ADRPromptDialog.tsx:33-45` uses raw `` elements with inline classes, while other dialogs use the `` component. This creates visual inconsistency.
+
+### 6c. No transition animations between states
+
+State changes (project switching, thread selection, sidebar toggle) happen instantly with no transitions. Even subtle 150ms fade-ins would improve perceived polish.
+
+---
+
+## 7. RECOMMENDED PRIORITY FIXES
+
+| Priority | Issue | Effort |
+|----------|-------|--------|
+| P0 | Render markdown in chat messages | Low |
+| P0 | Add loading state for project open | Low |
+| P0 | Surface errors as toast notifications | Medium |
+| P1 | Wire up dark mode toggle in Settings | Low |
+| P1 | Show document title in tabs, not just type | Low |
+| P1 | Add timestamps to ThreadListItem | Low |
+| P1 | Add `role="dialog"` and focus trap to Dialog | Medium |
+| P2 | Use custom design tokens instead of raw colors | Medium-High |
+| P2 | Add keyboard shortcuts for common actions | Medium |
+| P2 | Persist panel ratio in uiStore/ResizablePanel | Low |
+| P2 | Widen resizable panel drag handle | Low |
+| P2 | Add Shift+Enter hint to message input | Low |
+| P3 | Add thread search/filter | Medium |
+| P3 | Clamp SelectionToolbar position to viewport | Low |
+| P3 | Standardize Button usage across all dialogs | Low |
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 1833f59..f1cab9b 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -4,6 +4,8 @@ import { TitleBar } from './components/TitleBar'
import { Sidebar } from './components/Sidebar'
import { StatusBar } from './components/StatusBar'
import { ResizablePanel } from './components/ui/ResizablePanel'
+import { Spinner } from './components/ui/Spinner'
+import { ToastContainer, useToastStore } from './components/ui/Toast'
import { ConversationPanel } from './features/conversation/ConversationPanel'
import { DocumentPanel } from './features/document/DocumentPanel'
import { NewProjectDialog } from './features/project/NewProjectDialog'
@@ -16,18 +18,22 @@ import { useThreadStore } from './stores/threadStore'
import { useDocumentStore } from './stores/documentStore'
import { useSettingsStore } from './stores/settingsStore'
import { trpc } from './lib/trpc'
+import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { ADR_TEMPLATE } from '@shared/constants'
export function App() {
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const [showNewProject, setShowNewProject] = useState(false)
const [showSettings, setShowSettings] = useState(false)
+ const [loadingProject, setLoadingProject] = useState(false)
const [pendingPivot, setPendingPivot] = useState<{
previousDecision: string
newDecision: string
threadId: string
} | null>(null)
+ const addToast = useToastStore((s) => s.addToast)
+
const activeProject = useProjectStore((s) => s.activeProject)
const setProjects = useProjectStore((s) => s.setProjects)
const addProject = useProjectStore((s) => s.addProject)
@@ -48,6 +54,13 @@ export function App() {
const loadSettings = useSettingsStore((s) => s.loadSettings)
+ // Keyboard shortcuts
+ useKeyboardShortcuts({
+ onNewThread: () => { if (activeProject) handleNewThread() },
+ onOpenSettings: () => setShowSettings(true),
+ onNewProject: () => setShowNewProject(true),
+ })
+
// Check if the active project has any content
const hasContent = threads.length > 0 || documents.length > 0
@@ -79,22 +92,25 @@ export function App() {
setProjects(projects)
} catch (err) {
console.error('Failed to load projects:', err)
+ addToast('Failed to load projects. Please restart the app.')
}
}
loadProjects()
loadSettings()
- }, [setProjects, loadSettings])
+ }, [setProjects, loadSettings, addToast])
const handleCreateProject = useCallback(
async (name: string, path: string) => {
try {
const project = await trpc.project.create.mutate({ name, path })
addProject(project)
+ addToast(`Project "${name}" created successfully.`, 'success')
} catch (err) {
console.error('Failed to create project:', err)
+ addToast('Failed to create project. Please try again.')
}
},
- [addProject],
+ [addProject, addToast],
)
const handleNewThread = useCallback(async () => {
@@ -107,8 +123,9 @@ export function App() {
addThread(thread)
} catch (err) {
console.error('Failed to create thread:', err)
+ addToast('Failed to create thread. Please try again.')
}
- }, [activeProject, addThread])
+ }, [activeProject, addThread, addToast])
const handleSendMessage = useCallback(
async (content: string) => {
@@ -167,9 +184,10 @@ export function App() {
content: 'Failed to get a response. Please try again.',
createdAt: new Date().toISOString(),
})
+ addToast('Failed to get AI response. Check your provider settings.')
}
},
- [addMessage, addStreamingMessage, setStreamingMessageId],
+ [addMessage, addStreamingMessage, setStreamingMessageId, addToast],
)
const handleBranchThread = useCallback(
@@ -185,11 +203,13 @@ export function App() {
fromMessageId: messageId,
})
addThread(newThread)
+ addToast('Conversation branched successfully.', 'success')
} catch (err) {
console.error('Failed to branch thread:', err)
+ addToast('Failed to branch conversation. Please try again.')
}
},
- [addThread],
+ [addThread, addToast],
)
const handleInquire = useCallback(
@@ -207,9 +227,10 @@ export function App() {
addThread(thread)
} catch (err) {
console.error('Failed to create inquiry thread:', err)
+ addToast('Failed to create inquiry. Please try again.')
}
},
- [activeProject, addThread],
+ [activeProject, addThread, addToast],
)
const handleRefine = useCallback(
@@ -227,13 +248,15 @@ export function App() {
addThread(thread)
} catch (err) {
console.error('Failed to create refinement thread:', err)
+ addToast('Failed to create refinement. Please try again.')
}
},
- [activeProject, addThread],
+ [activeProject, addThread, addToast],
)
const handleSelectProject = useCallback(
async (_path: string) => {
+ setLoadingProject(true)
try {
// Open project via backend (loads full project with doc/thread refs)
const project = await trpc.project.open.mutate({ path: _path })
@@ -275,9 +298,12 @@ export function App() {
setDocuments(fullDocs)
} catch (err) {
console.error('Failed to select project:', err)
+ addToast('Failed to open project. Please try again.')
+ } finally {
+ setLoadingProject(false)
}
},
- [setActiveProject, updateThreads, updateDocuments, setThreads, setDocuments],
+ [setActiveProject, updateThreads, updateDocuments, setThreads, setDocuments, addToast],
)
const handleCreateADR = useCallback(async () => {
@@ -335,10 +361,12 @@ export function App() {
// Clear pending pivot
setPendingPivot(null)
+ addToast('ADR created successfully.', 'success')
} catch (err) {
console.error('Failed to create ADR:', err)
+ addToast('Failed to create ADR. Please try again.')
}
- }, [pendingPivot, activeProject, addDocument, updateDocuments])
+ }, [pendingPivot, activeProject, addDocument, updateDocuments, addToast])
return (
@@ -351,7 +379,14 @@ export function App() {
{sidebarOpen &&
}
- {activeProject ? (
+ {loadingProject ? (
+
+ ) : activeProject ? (
hasContent ? (
+
+
setShowNewProject(false)}
diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx
index 41a8414..9acc803 100644
--- a/src/renderer/components/Sidebar.tsx
+++ b/src/renderer/components/Sidebar.tsx
@@ -9,13 +9,13 @@ export function Sidebar({ onSelectProject }: SidebarProps) {
const activeProject = useProjectStore((s) => s.activeProject)
return (
-
+
Projects
-
+
{projects.length === 0 ? (
No projects yet. Create one to get started.
@@ -26,6 +26,7 @@ export function Sidebar({ onSelectProject }: SidebarProps) {
onSelectProject(project.path)}
+ aria-current={activeProject?.id === project.id ? 'page' : undefined}
className={`w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
activeProject?.id === project.id
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
@@ -37,7 +38,7 @@ export function Sidebar({ onSelectProject }: SidebarProps) {
))}
)}
-
-
+
+
)
}
diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx
index 4c82919..03dd968 100644
--- a/src/renderer/components/StatusBar.tsx
+++ b/src/renderer/components/StatusBar.tsx
@@ -5,13 +5,16 @@ export function StatusBar() {
const activeProvider = useSettingsStore((s) => s.activeProvider)
return (
-
+
Keystone v{APP_VERSION}
{activeProvider ? (
- <>AI: {activeProvider}>
+
+
+ AI: {activeProvider}
+
) : (
- No AI provider configured
+ No AI provider configured
)}
diff --git a/src/renderer/components/TitleBar.tsx b/src/renderer/components/TitleBar.tsx
index 86fdc3a..9504484 100644
--- a/src/renderer/components/TitleBar.tsx
+++ b/src/renderer/components/TitleBar.tsx
@@ -7,6 +7,7 @@ interface TitleBarProps {
}
export function TitleBar({ onOpenSettings, onNewProject }: TitleBarProps) {
+ const sidebarOpen = useUIStore((s) => s.sidebarOpen)
const toggleSidebar = useUIStore((s) => s.toggleSidebar)
const activeProject = useProjectStore((s) => s.activeProject)
@@ -20,8 +21,10 @@ export function TitleBar({ onOpenSettings, onNewProject }: TitleBarProps) {
onClick={toggleSidebar}
className="rounded p-1 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
+ aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
+ aria-expanded={sidebarOpen}
>
-
+
@@ -38,8 +41,9 @@ export function TitleBar({ onOpenSettings, onNewProject }: TitleBarProps) {
onClick={onNewProject}
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
title="New Project"
+ aria-label="Create new project"
>
-
+
@@ -47,8 +51,9 @@ export function TitleBar({ onOpenSettings, onNewProject }: TitleBarProps) {
onClick={onOpenSettings}
className="rounded p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
title="Settings"
+ aria-label="Open settings"
>
-
+
diff --git a/src/renderer/components/ui/Dialog.tsx b/src/renderer/components/ui/Dialog.tsx
index e02d7ac..1e55868 100644
--- a/src/renderer/components/ui/Dialog.tsx
+++ b/src/renderer/components/ui/Dialog.tsx
@@ -1,4 +1,4 @@
-import { type ReactNode, useEffect, useRef } from 'react'
+import { type ReactNode, useEffect, useRef, useCallback } from 'react'
interface DialogProps {
open: boolean
@@ -9,27 +9,76 @@ interface DialogProps {
export function Dialog({ open, onClose, title, children }: DialogProps) {
const dialogRef = useRef(null)
+ const previousFocusRef = useRef(null)
+
+ // Focus trap: keep focus within dialog
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose()
+ return
+ }
+
+ if (e.key !== 'Tab' || !dialogRef.current) return
+
+ const focusableElements = dialogRef.current.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ )
+ const firstEl = focusableElements[0]
+ const lastEl = focusableElements[focusableElements.length - 1]
+
+ if (e.shiftKey) {
+ if (document.activeElement === firstEl) {
+ e.preventDefault()
+ lastEl?.focus()
+ }
+ } else {
+ if (document.activeElement === lastEl) {
+ e.preventDefault()
+ firstEl?.focus()
+ }
+ }
+ },
+ [onClose],
+ )
useEffect(() => {
- const handleEsc = (e: KeyboardEvent) => {
- if (e.key === 'Escape') onClose()
- }
if (open) {
- document.addEventListener('keydown', handleEsc)
+ previousFocusRef.current = document.activeElement as HTMLElement
+ document.addEventListener('keydown', handleKeyDown)
+
+ // Focus the first focusable element in the dialog
+ requestAnimationFrame(() => {
+ const firstFocusable = dialogRef.current?.querySelector(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
+ )
+ firstFocusable?.focus()
+ })
+ }
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ // Return focus to previously focused element
+ if (previousFocusRef.current) {
+ previousFocusRef.current.focus()
+ previousFocusRef.current = null
+ }
}
- return () => document.removeEventListener('keydown', handleEsc)
- }, [open, onClose])
+ }, [open, handleKeyDown])
if (!open) return null
return (
-
-
+
+
-
{title}
+ {title}
{children}
diff --git a/src/renderer/components/ui/ResizablePanel.tsx b/src/renderer/components/ui/ResizablePanel.tsx
index d801439..1221171 100644
--- a/src/renderer/components/ui/ResizablePanel.tsx
+++ b/src/renderer/components/ui/ResizablePanel.tsx
@@ -1,9 +1,9 @@
-import { type ReactNode, useState, useCallback, useRef, useEffect } from 'react'
+import { type ReactNode, useCallback, useRef, useEffect } from 'react'
+import { useUIStore } from '../../stores/uiStore'
interface ResizablePanelProps {
left: ReactNode
right: ReactNode
- defaultRatio?: number
minRatio?: number
maxRatio?: number
}
@@ -11,11 +11,11 @@ interface ResizablePanelProps {
export function ResizablePanel({
left,
right,
- defaultRatio = 0.5,
minRatio = 0.2,
maxRatio = 0.8,
}: ResizablePanelProps) {
- const [ratio, setRatio] = useState(defaultRatio)
+ const ratio = useUIStore((s) => s.panelRatio)
+ const setPanelRatio = useUIStore((s) => s.setPanelRatio)
const containerRef = useRef
(null)
const isDragging = useRef(false)
@@ -31,7 +31,7 @@ export function ResizablePanel({
if (!isDragging.current || !containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const newRatio = Math.min(maxRatio, Math.max(minRatio, (e.clientX - rect.left) / rect.width))
- setRatio(newRatio)
+ setPanelRatio(newRatio)
}
const handleMouseUp = () => {
@@ -46,7 +46,7 @@ export function ResizablePanel({
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
- }, [minRatio, maxRatio])
+ }, [minRatio, maxRatio, setPanelRatio])
return (
@@ -55,8 +55,15 @@ export function ResizablePanel({
+ role="separator"
+ aria-orientation="vertical"
+ aria-valuenow={Math.round(ratio * 100)}
+ tabIndex={0}
+ className="group relative w-1.5 cursor-col-resize bg-gray-200 transition-colors hover:bg-indigo-400 dark:bg-gray-700 dark:hover:bg-indigo-500"
+ >
+ {/* Wider invisible hit zone for easier grabbing */}
+
+
{right}
diff --git a/src/renderer/components/ui/Toast.tsx b/src/renderer/components/ui/Toast.tsx
new file mode 100644
index 0000000..1467eeb
--- /dev/null
+++ b/src/renderer/components/ui/Toast.tsx
@@ -0,0 +1,80 @@
+import { useEffect } from 'react'
+import { create } from 'zustand'
+
+interface ToastItem {
+ id: string
+ message: string
+ type: 'error' | 'success' | 'info'
+}
+
+interface ToastState {
+ toasts: ToastItem[]
+ addToast: (message: string, type?: ToastItem['type']) => void
+ removeToast: (id: string) => void
+}
+
+export const useToastStore = create((set) => ({
+ toasts: [],
+ addToast: (message, type = 'error') => {
+ const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
+ set((state) => ({ toasts: [...state.toasts, { id, message, type }] }))
+ },
+ removeToast: (id) => {
+ set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }))
+ },
+}))
+
+const typeStyles: Record = {
+ error: 'bg-red-600 text-white',
+ success: 'bg-green-600 text-white',
+ info: 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900',
+}
+
+const typeIcons: Record = {
+ error: 'M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z',
+ success: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
+ info: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
+}
+
+function ToastItem({ toast, onDismiss }: { toast: ToastItem; onDismiss: () => void }) {
+ useEffect(() => {
+ const timer = setTimeout(onDismiss, 5000)
+ return () => clearTimeout(timer)
+ }, [onDismiss])
+
+ return (
+
+
+
+
+
{toast.message}
+
+
+
+
+
+
+ )
+}
+
+export function ToastContainer() {
+ const toasts = useToastStore((s) => s.toasts)
+ const removeToast = useToastStore((s) => s.removeToast)
+
+ if (toasts.length === 0) return null
+
+ return (
+
+ {toasts.map((toast) => (
+ removeToast(toast.id)} />
+ ))}
+
+ )
+}
diff --git a/src/renderer/features/conversation/ChatView.tsx b/src/renderer/features/conversation/ChatView.tsx
index 3441d3c..4f556c3 100644
--- a/src/renderer/features/conversation/ChatView.tsx
+++ b/src/renderer/features/conversation/ChatView.tsx
@@ -15,7 +15,7 @@ export function ChatView({ messages, streamingMessageId, isStreaming, onSendMess
return (
- {isStreaming && !streamingMessageId && }
+ {isStreaming && }
)
diff --git a/src/renderer/features/conversation/ConversationPanel.tsx b/src/renderer/features/conversation/ConversationPanel.tsx
index 5535bf7..6ea64a3 100644
--- a/src/renderer/features/conversation/ConversationPanel.tsx
+++ b/src/renderer/features/conversation/ConversationPanel.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react'
import { ThreadList } from './ThreadList'
import { ChatView } from './ChatView'
import { useThreadStore } from '../../stores/threadStore'
@@ -13,6 +14,7 @@ export function ConversationPanel({ onSendMessage, onNewThread, onBranch }: Conv
const activeThreadId = useThreadStore((s) => s.activeThreadId)
const streamingMessageId = useThreadStore((s) => s.streamingMessageId)
const setActiveThread = useThreadStore((s) => s.setActiveThread)
+ const [threadListCollapsed, setThreadListCollapsed] = useState(false)
const activeThread = threads.find((t) => t.id === activeThreadId)
const threadInfos = threads.map((t) => ({
@@ -24,31 +26,57 @@ export function ConversationPanel({ onSendMessage, onNewThread, onBranch }: Conv
return (
-
-
-
-
- {activeThread ? (
-
+
- ) : (
-
-
-
No thread selected
-
Create a new thread to start a conversation
+
+ )}
+
+ {/* Thread list collapse toggle */}
+
+
setThreadListCollapsed(!threadListCollapsed)}
+ className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
+ aria-label={threadListCollapsed ? 'Show thread list' : 'Hide thread list'}
+ aria-expanded={!threadListCollapsed}
+ >
+
+ {threadListCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+ {activeThread && (
+
+ {activeThread.title}
+
+ )}
+
+
+ {activeThread ? (
+
+ ) : (
+
+
+
No thread selected
+
Create a new thread to start a conversation
+
-
- )}
+ )}
+
)
diff --git a/src/renderer/features/conversation/MessageBubble.tsx b/src/renderer/features/conversation/MessageBubble.tsx
index b11115f..0ef89b4 100644
--- a/src/renderer/features/conversation/MessageBubble.tsx
+++ b/src/renderer/features/conversation/MessageBubble.tsx
@@ -1,4 +1,6 @@
+import { useState, useEffect } from 'react'
import type { Message } from '@shared/types'
+import { renderMarkdown } from '@/lib/markdown'
interface MessageBubbleProps {
message: Message
@@ -11,6 +13,14 @@ export function MessageBubble({ message, isStreaming, onBranch }: MessageBubbleP
const isSystem = message.role === 'system'
const isAssistant = message.role === 'assistant'
+ const [renderedHtml, setRenderedHtml] = useState
('')
+
+ useEffect(() => {
+ if (isAssistant && message.content) {
+ renderMarkdown(message.content).then(setRenderedHtml)
+ }
+ }, [isAssistant, message.content])
+
if (isSystem) {
return (
@@ -26,15 +36,22 @@ export function MessageBubble({ message, isStreaming, onBranch }: MessageBubbleP
K
)}
-
+
-
{message.content}
+ {isAssistant && renderedHtml ? (
+
+ ) : (
+
{message.content}
+ )}
{isStreaming && (
)}
@@ -44,6 +61,7 @@ export function MessageBubble({ message, isStreaming, onBranch }: MessageBubbleP
onClick={() => onBranch(message.id)}
className="mt-1 self-start rounded px-1.5 py-0.5 text-xs text-gray-400 opacity-0 transition-opacity hover:bg-gray-100 hover:text-gray-600 group-hover:opacity-100 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Branch conversation from this message"
+ aria-label="Branch conversation from this message"
>
Branch
diff --git a/src/renderer/features/conversation/MessageInput.tsx b/src/renderer/features/conversation/MessageInput.tsx
index 4c55cd2..6bfc307 100644
--- a/src/renderer/features/conversation/MessageInput.tsx
+++ b/src/renderer/features/conversation/MessageInput.tsx
@@ -36,6 +36,7 @@ export function MessageInput({ onSend, disabled, placeholder = 'Type a message..
placeholder={placeholder}
disabled={disabled}
rows={1}
+ aria-label="Message input"
className="flex-1 resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:placeholder:text-gray-500"
style={{ maxHeight: '120px' }}
onInput={(e) => {
@@ -48,12 +49,17 @@ export function MessageInput({ onSend, disabled, placeholder = 'Type a message..
onClick={handleSend}
disabled={disabled || !value.trim()}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-600 text-white transition-colors hover:bg-indigo-700 disabled:opacity-50"
+ aria-label="Send message"
>
-
+
+
+ Enter to send,{' '}
+ Shift+Enter for new line
+
)
}
diff --git a/src/renderer/features/conversation/StreamingIndicator.tsx b/src/renderer/features/conversation/StreamingIndicator.tsx
index 1448e03..5e72d6a 100644
--- a/src/renderer/features/conversation/StreamingIndicator.tsx
+++ b/src/renderer/features/conversation/StreamingIndicator.tsx
@@ -1,14 +1,14 @@
export function StreamingIndicator() {
return (
-
+
-
-
Keystone is thinking...
+
Keystone is thinking...
)
}
diff --git a/src/renderer/features/conversation/ThreadList.tsx b/src/renderer/features/conversation/ThreadList.tsx
index 2f523af..9d42b52 100644
--- a/src/renderer/features/conversation/ThreadList.tsx
+++ b/src/renderer/features/conversation/ThreadList.tsx
@@ -1,3 +1,4 @@
+import { useState, useMemo } from 'react'
import { ThreadListItem } from './ThreadListItem'
import { Button } from '../../components/ui/Button'
@@ -16,24 +17,45 @@ interface ThreadListProps {
}
export function ThreadList({ threads, activeThreadId, onSelectThread, onNewThread }: ThreadListProps) {
+ const [searchQuery, setSearchQuery] = useState('')
const activeThreads = threads.filter((t) => t.status === 'active')
+ const filteredThreads = useMemo(() => {
+ if (!searchQuery.trim()) return activeThreads
+ const query = searchQuery.toLowerCase()
+ return activeThreads.filter((t) => t.title.toLowerCase().includes(query))
+ }, [activeThreads, searchQuery])
+
return (
Threads
-
+
+ New
+ {activeThreads.length > 3 && (
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search threads..."
+ aria-label="Search threads"
+ className="w-full rounded-md border border-gray-200 bg-white px-2.5 py-1 text-xs placeholder:text-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:placeholder:text-gray-500"
+ />
+
+ )}
{activeThreads.length === 0 ? (
No threads yet
+ ) : filteredThreads.length === 0 ? (
+
No matching threads
) : (
- {activeThreads.map((thread) => (
+ {filteredThreads.map((thread) => (
void
}
-export function ThreadListItem({ title, isActive, onClick }: ThreadListItemProps) {
+function formatRelativeTime(dateStr: string): string {
+ const date = new Date(dateStr)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return 'just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ if (diffDays < 7) return `${diffDays}d ago`
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
+}
+
+export function ThreadListItem({ title, isActive, updatedAt, onClick }: ThreadListItemProps) {
return (
{title}
+ {updatedAt && (
+
+ {formatRelativeTime(updatedAt)}
+
+ )}
)
}
diff --git a/src/renderer/features/document/ADRPromptDialog.tsx b/src/renderer/features/document/ADRPromptDialog.tsx
index 983487a..1037141 100644
--- a/src/renderer/features/document/ADRPromptDialog.tsx
+++ b/src/renderer/features/document/ADRPromptDialog.tsx
@@ -1,4 +1,5 @@
import { Dialog } from '../../components/ui/Dialog'
+import { Button } from '../../components/ui/Button'
interface ADRPromptDialogProps {
open: boolean
@@ -30,18 +31,12 @@ export function ADRPromptDialog({
-
+
Dismiss
-
-
+
+
Create ADR
-
+
diff --git a/src/renderer/features/document/DocumentOutline.tsx b/src/renderer/features/document/DocumentOutline.tsx
index 67ca6a7..00496c1 100644
--- a/src/renderer/features/document/DocumentOutline.tsx
+++ b/src/renderer/features/document/DocumentOutline.tsx
@@ -10,14 +10,14 @@ export function DocumentOutline({ content }: DocumentOutlineProps) {
if (headings.length === 0) return null
return (
-
-
Outline
-
+
+
Outline
+
{headings.map((heading, i) => (
{heading.text}
diff --git a/src/renderer/features/document/DocumentTabs.tsx b/src/renderer/features/document/DocumentTabs.tsx
index 45594fe..ba0821c 100644
--- a/src/renderer/features/document/DocumentTabs.tsx
+++ b/src/renderer/features/document/DocumentTabs.tsx
@@ -10,18 +10,25 @@ export function DocumentTabs({ documents, activeDocumentId, onSelect }: Document
if (documents.length === 0) return null
return (
-
+
{documents.map((doc) => (
onSelect(doc.id)}
- className={`border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
+ role="tab"
+ aria-selected={doc.id === activeDocumentId}
+ className={`flex-shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors ${
doc.id === activeDocumentId
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
- {doc.type.toUpperCase()}
+ {doc.type}
+ {doc.title && (
+
+ {doc.title}
+
+ )}
))}
diff --git a/src/renderer/features/document/SelectionToolbar.tsx b/src/renderer/features/document/SelectionToolbar.tsx
index bcd2e46..fae9612 100644
--- a/src/renderer/features/document/SelectionToolbar.tsx
+++ b/src/renderer/features/document/SelectionToolbar.tsx
@@ -1,3 +1,5 @@
+import { useRef, useEffect, useState } from 'react'
+
interface SelectionToolbarProps {
position: { top: number; left: number }
onInquire: () => void
@@ -5,27 +7,55 @@ interface SelectionToolbarProps {
}
export function SelectionToolbar({ position, onInquire, onRefine }: SelectionToolbarProps) {
+ const toolbarRef = useRef
(null)
+ const [clampedPos, setClampedPos] = useState(position)
+
+ useEffect(() => {
+ if (!toolbarRef.current) {
+ setClampedPos(position)
+ return
+ }
+ const el = toolbarRef.current
+ const parent = el.offsetParent as HTMLElement | null
+ if (!parent) {
+ setClampedPos(position)
+ return
+ }
+
+ const parentWidth = parent.clientWidth
+ const elWidth = el.offsetWidth
+ const clampedLeft = Math.max(4, Math.min(position.left, parentWidth - elWidth - 4))
+ const clampedTop = Math.max(4, position.top)
+
+ setClampedPos({ top: clampedTop, left: clampedLeft })
+ }, [position])
+
return (
-
+
Inquire
-
+
-
+
Refine
diff --git a/src/renderer/features/settings/ProviderCard.tsx b/src/renderer/features/settings/ProviderCard.tsx
index df7cccc..dc2c76b 100644
--- a/src/renderer/features/settings/ProviderCard.tsx
+++ b/src/renderer/features/settings/ProviderCard.tsx
@@ -41,13 +41,15 @@ export function ProviderCard({ type, name, isActive, onSelect }: ProviderCardPro
-
{name}
+
{name}
{isExperimental && (
Experimental
@@ -75,8 +77,9 @@ export function ProviderCard({ type, name, isActive, onSelect }: ProviderCardPro
}}
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
title="Disconnect"
+ aria-label={`Disconnect ${name}`}
>
-
+
diff --git a/src/renderer/features/settings/SettingsDialog.tsx b/src/renderer/features/settings/SettingsDialog.tsx
index 50f0288..3abceab 100644
--- a/src/renderer/features/settings/SettingsDialog.tsx
+++ b/src/renderer/features/settings/SettingsDialog.tsx
@@ -3,6 +3,7 @@ import { Dialog } from '../../components/ui/Dialog'
import { Input } from '../../components/ui/Input'
import { Button } from '../../components/ui/Button'
import { useSettingsStore } from '../../stores/settingsStore'
+import { useUIStore } from '../../stores/uiStore'
import { ProviderCard } from './ProviderCard'
import type { ProviderType } from '@shared/types'
@@ -17,8 +18,16 @@ const providers: Array<{ type: ProviderType; name: string; placeholder: string }
{ type: 'google', name: 'Google (Gemini)', placeholder: 'AI...' },
]
+const themeOptions = [
+ { value: 'light' as const, label: 'Light' },
+ { value: 'dark' as const, label: 'Dark' },
+ { value: 'system' as const, label: 'System' },
+]
+
export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
const { apiKeys, activeProvider, setApiKey, setActiveProvider, loadSettings } = useSettingsStore()
+ const theme = useUIStore((s) => s.theme)
+ const setTheme = useUIStore((s) => s.setTheme)
const [showAdvanced, setShowAdvanced] = useState(false)
useEffect(() => {
@@ -43,6 +52,27 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
return (
+ {/* Theme selector */}
+
+
Appearance
+
+ {themeOptions.map((opt) => (
+ setTheme(opt.value)}
+ className={`rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
+ theme === opt.value
+ ? 'border-indigo-500 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
+ : 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:bg-gray-800'
+ }`}
+ aria-pressed={theme === opt.value}
+ >
+ {opt.label}
+
+ ))}
+
+
+
AI Providers
@@ -64,6 +94,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
+ aria-expanded={showAdvanced}
>
diff --git a/src/renderer/globals.css b/src/renderer/globals.css
index c55b037..54fef61 100644
--- a/src/renderer/globals.css
+++ b/src/renderer/globals.css
@@ -13,7 +13,7 @@
-webkit-font-smoothing: antialiased;
}
- /* Hide scrollbar but keep functionality */
+ /* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
@@ -32,3 +32,11 @@
@apply bg-gray-400 dark:bg-gray-500;
}
}
+
+/* Keyboard shortcut hint styling */
+@layer components {
+ kbd {
+ font-family: inherit;
+ font-size: 0.7rem;
+ }
+}
diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts
new file mode 100644
index 0000000..592045d
--- /dev/null
+++ b/src/renderer/hooks/useKeyboardShortcuts.ts
@@ -0,0 +1,45 @@
+import { useEffect } from 'react'
+import { useUIStore } from '../stores/uiStore'
+
+interface ShortcutHandlers {
+ onNewThread?: () => void
+ onOpenSettings?: () => void
+ onNewProject?: () => void
+}
+
+export function useKeyboardShortcuts({ onNewThread, onOpenSettings, onNewProject }: ShortcutHandlers) {
+ const toggleSidebar = useUIStore((s) => s.toggleSidebar)
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const mod = e.metaKey || e.ctrlKey
+
+ // Cmd/Ctrl+B: Toggle sidebar
+ if (mod && e.key === 'b') {
+ e.preventDefault()
+ toggleSidebar()
+ }
+
+ // Cmd/Ctrl+N: New thread
+ if (mod && e.key === 'n' && !e.shiftKey) {
+ e.preventDefault()
+ onNewThread?.()
+ }
+
+ // Cmd/Ctrl+Shift+N: New project
+ if (mod && e.key === 'N' && e.shiftKey) {
+ e.preventDefault()
+ onNewProject?.()
+ }
+
+ // Cmd/Ctrl+,: Open settings
+ if (mod && e.key === ',') {
+ e.preventDefault()
+ onOpenSettings?.()
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown)
+ return () => document.removeEventListener('keydown', handleKeyDown)
+ }, [toggleSidebar, onNewThread, onOpenSettings, onNewProject])
+}
diff --git a/src/renderer/stores/uiStore.ts b/src/renderer/stores/uiStore.ts
index 49d2edc..a12ecf0 100644
--- a/src/renderer/stores/uiStore.ts
+++ b/src/renderer/stores/uiStore.ts
@@ -1,14 +1,26 @@
import { create } from 'zustand'
+type Theme = 'light' | 'dark' | 'system'
+
+function applyTheme(theme: Theme): void {
+ const root = document.documentElement
+ if (theme === 'system') {
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
+ root.classList.toggle('dark', prefersDark)
+ } else {
+ root.classList.toggle('dark', theme === 'dark')
+ }
+}
+
interface UIState {
sidebarOpen: boolean
panelRatio: number
activeModal: string | null
- theme: 'light' | 'dark' | 'system'
+ theme: Theme
toggleSidebar: () => void
setPanelRatio: (ratio: number) => void
setActiveModal: (modal: string | null) => void
- setTheme: (theme: 'light' | 'dark' | 'system') => void
+ setTheme: (theme: Theme) => void
}
export const useUIStore = create
((set) => ({
@@ -19,5 +31,19 @@ export const useUIStore = create((set) => ({
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setPanelRatio: (ratio) => set({ panelRatio: ratio }),
setActiveModal: (modal) => set({ activeModal: modal }),
- setTheme: (theme) => set({ theme }),
+ setTheme: (theme) => {
+ applyTheme(theme)
+ set({ theme })
+ },
}))
+
+// Apply initial theme on load
+applyTheme(useUIStore.getState().theme)
+
+// Listen for system theme changes when using 'system' mode
+window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ const currentTheme = useUIStore.getState().theme
+ if (currentTheme === 'system') {
+ applyTheme('system')
+ }
+})
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 95263a2..a67495f 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -17,7 +17,27 @@ const config: Config = {
accent: {
DEFAULT: '#6366f1',
hover: '#4f46e5',
+ light: '#e0e7ff',
+ muted: '#818cf8',
},
+ muted: {
+ DEFAULT: '#6b7280',
+ foreground: '#9ca3af',
+ },
+ },
+ keyframes: {
+ 'slide-in-from-right': {
+ from: { transform: 'translateX(100%)', opacity: '0' },
+ to: { transform: 'translateX(0)', opacity: '1' },
+ },
+ 'fade-in': {
+ from: { opacity: '0' },
+ to: { opacity: '1' },
+ },
+ },
+ animation: {
+ 'slide-in-from-right': 'slide-in-from-right 0.2s ease-out',
+ 'fade-in': 'fade-in 0.15s ease-out',
},
},
},