diff --git a/assets/screenshots/resumeupdate.PNG b/assets/screenshots/resumeupdate.PNG new file mode 100644 index 0000000..d8d0d9d Binary files /dev/null and b/assets/screenshots/resumeupdate.PNG differ diff --git a/docs/architecture/subsystems/resume-pipeline.md b/docs/architecture/subsystems/resume-pipeline.md index 7172fc8..7839b7e 100644 --- a/docs/architecture/subsystems/resume-pipeline.md +++ b/docs/architecture/subsystems/resume-pipeline.md @@ -268,6 +268,12 @@ Exposed on the preload bridge as the `window.limboo.resume` namespace; see the Matching the existing shell idioms exactly (pure-black tokens, no new patterns): +Resume banner under the session header, the Revalidating chip in the header, and the repository-delta dialog + +The three surfaces work together: the **`ResumeBanner`** announces drift under the +session header, the **Revalidating chip** shows the non-blocking check in flight, and +**`ResumeDeltaDialog`** presents the full structured delta on demand. + - **`ResumeBanner`** — a row under the session header cloning the missing-worktree banner (`h-9`, `border-b border-line bg-surface`): an info tone for ordinary drift, a warning tone for a rewrite or root change. "Review" opens the detail diff --git a/src/renderer/components/ui/SuccessCheck.tsx b/src/renderer/components/ui/SuccessCheck.tsx new file mode 100644 index 0000000..5b24670 --- /dev/null +++ b/src/renderer/components/ui/SuccessCheck.tsx @@ -0,0 +1,37 @@ +/** + * SuccessCheck — a modern, self-drawing success checkmark. The ring and tick are + * revealed with a `stroke-dashoffset` draw (GPU-accelerated, no deps), tinted with + * the `success` token via `currentColor`. The animation keyframes live in + * `styles/index.css` and are disabled under reduced motion (the stroke renders + * fully drawn). Purely presentational. + */ +export function SuccessCheck({ size = 48, className }: { size?: number; className?: string }) { + return ( + + + + + ); +} diff --git a/src/renderer/components/ui/index.ts b/src/renderer/components/ui/index.ts index ed97b45..26a0c55 100644 --- a/src/renderer/components/ui/index.ts +++ b/src/renderer/components/ui/index.ts @@ -5,5 +5,6 @@ export { EmptyState } from './EmptyState'; export { Badge } from './Badge'; export { Kbd } from './Kbd'; export { Spinner } from './Spinner'; +export { SuccessCheck } from './SuccessCheck'; export { CircularProgress } from './CircularProgress'; export { Waveform } from './Waveform'; diff --git a/src/renderer/features/activity/PlanPanel.tsx b/src/renderer/features/activity/PlanPanel.tsx index 4e9a412..a73d4c1 100644 --- a/src/renderer/features/activity/PlanPanel.tsx +++ b/src/renderer/features/activity/PlanPanel.tsx @@ -47,7 +47,7 @@ import type { SessionPlan, TaskItem, } from '@shared/types'; -import { EmptyState, IconButton, Spinner } from '@/renderer/components/ui'; +import { EmptyState, IconButton, Spinner, SuccessCheck } from '@/renderer/components/ui'; import { cn } from '@/renderer/lib/cn'; import { applyRuntime, @@ -324,6 +324,22 @@ function PlanView({ /> )} + {/* Prominent completion banner — a large self-drawing checkmark when the + whole plan run has finished, so "execution is done" is impossible to miss. */} + {plan.status === 'completed' && ( +
+ +
+ Execution complete + + {hasProgress + ? `All ${progress.total} task${progress.total === 1 ? '' : 's'} finished.` + : 'The agent finished this plan.'} + +
+
+ )} + {/* Task outline (or the flat checklist fallback) */} {hasOutline ? ( (null); const highlightTimer = useRef | null>(null); + // Snapshot of settings captured when the modal opened — the "saved baseline" + // used to detect (and revert) changes made during this session. + const baselineRef = useRef(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [changes, setChanges] = useState([]); + // Reset to a clean state each time the modal is opened. useEffect(() => { if (open) { setActiveId(DEFAULT_CATEGORY); setQuery(''); setHighlight(null); + setConfirmOpen(false); + // Capture the baseline (layout is persisted separately and excluded below). + baselineRef.current = useSettingsStore.getState().settings; } }, [open]); @@ -37,6 +50,50 @@ export function SettingsModal() { if (highlightTimer.current) clearTimeout(highlightTimer.current); }, []); + // Compute the pending changes vs. the opening baseline (excluding layout, which + // the modal never edits and which persists on its own). + const pendingChanges = (): string[] => { + const base = baselineRef.current; + if (!base) return []; + return diffSettings(base, useSettingsStore.getState().settings).filter( + (p) => !p.startsWith('layout'), + ); + }; + + // Ambiguous close (X / backdrop / Escape): confirm if there are pending changes. + const attemptClose = () => { + const diff = pendingChanges(); + if (diff.length > 0) { + setChanges(diff); + setConfirmOpen(true); + } else { + close(); + } + }; + + const keepEditing = () => setConfirmOpen(false); + + // Revert every changed leaf back to the opening baseline, then close. The full + // baseline is a complete override for the deep-merge in `update`; layout cannot + // change while the modal is open, so re-applying it is a harmless no-op. + const discardChanges = () => { + const base = baselineRef.current; + if (base) void useSettingsStore.getState().update(base); + setConfirmOpen(false); + close(); + }; + + // Escape closes via the confirm path (the nested dialog handles its own Escape + // in capture phase, so this only fires when the confirm is not open). + useEffect(() => { + if (!open || confirmOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') attemptClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, confirmOpen]); + if (!open) return null; const selectCategory = (id: string) => { @@ -55,9 +112,10 @@ export function SettingsModal() { const Panel = category.Panel; return ( + <>
@@ -102,5 +160,14 @@ export function SettingsModal() {
+ + {confirmOpen && ( + + )} + ); } diff --git a/src/renderer/features/settings/UnsavedSettingsDialog.tsx b/src/renderer/features/settings/UnsavedSettingsDialog.tsx new file mode 100644 index 0000000..7909262 --- /dev/null +++ b/src/renderer/features/settings/UnsavedSettingsDialog.tsx @@ -0,0 +1,93 @@ +/** + * Unsaved-settings confirm — a centered modal shown when the Settings modal is + * closed (X / backdrop / Escape) while changes differ from the values captured + * when it opened. Lists the changed settings and offers to keep editing or + * discard (revert to the opening baseline). Built to the app modal idiom + * (HooksConfirmDialog / ResumeDeltaDialog): same backdrop, pop-in card, and + * button classes. Dark-only, no gradients. + */ +import { useEffect } from 'react'; +import { AlertTriangle, X } from 'lucide-react'; +import { humanizeSettingPath } from './diffSettings'; + +export function UnsavedSettingsDialog({ + changes, + onKeepEditing, + onDiscard, +}: { + changes: string[]; + onKeepEditing: () => void; + onDiscard: () => void; +}) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onKeepEditing(); + } + }; + // Capture so this nested dialog handles Escape before the Settings modal does. + window.addEventListener('keydown', onKey, true); + return () => window.removeEventListener('keydown', onKey, true); + }, [onKeepEditing]); + + return ( +
+
e.stopPropagation()} + > +
+ + + Discard unsaved changes? + + +
+ +
+

+ You changed {changes.length} setting{changes.length === 1 ? '' : 's'} in this + session. Discarding reverts {changes.length === 1 ? 'it' : 'them'} to the values + from when you opened Settings. +

+
+ {changes.map((path) => ( +
+ + {humanizeSettingPath(path)} +
+ ))} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/renderer/features/settings/diffSettings.ts b/src/renderer/features/settings/diffSettings.ts new file mode 100644 index 0000000..e35623d --- /dev/null +++ b/src/renderer/features/settings/diffSettings.ts @@ -0,0 +1,54 @@ +/** + * A tiny deep-diff for settings. Returns the dot-notation leaf paths whose value + * changed between a baseline snapshot and the current settings. Objects recurse; + * scalars and arrays compare by value (arrays via shallow JSON, which is exact for + * the primitive-only arrays settings use). Purely presentational — it drives the + * "unsaved changes" confirm list; no state escapes. + */ +type Json = unknown; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function equalLeaf(a: Json, b: Json): boolean { + if (a === b) return true; + // Arrays / mixed leaves: compare by stable serialization. + return JSON.stringify(a) === JSON.stringify(b); +} + +function walk(baseline: Json, current: Json, prefix: string, out: string[]): void { + if (isPlainObject(baseline) && isPlainObject(current)) { + const keys = new Set([...Object.keys(baseline), ...Object.keys(current)]); + for (const key of keys) { + walk(baseline[key], current[key], prefix ? `${prefix}.${key}` : key, out); + } + return; + } + if (!equalLeaf(baseline, current)) out.push(prefix); +} + +/** Changed leaf paths (dot notation) between two settings objects. */ +export function diffSettings(baseline: T, current: T): string[] { + const out: string[] = []; + walk(baseline, current, '', out); + return out; +} + +/** + * Humanize a settings dot-path into a readable "Section › Field" label: + * `agent.plan.defaultMode` → "Agent › Plan › Default Mode". Splits camelCase and + * title-cases each segment. Keeps the layout key out (it's not user-facing here). + */ +export function humanizeSettingPath(path: string): string { + return path + .split('.') + .map((seg) => + seg + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(), + ) + .join(' › '); +} diff --git a/src/renderer/features/workspace/CenterWorkspace.tsx b/src/renderer/features/workspace/CenterWorkspace.tsx index 37c5253..e98316f 100644 --- a/src/renderer/features/workspace/CenterWorkspace.tsx +++ b/src/renderer/features/workspace/CenterWorkspace.tsx @@ -12,8 +12,8 @@ * hack that raced the real height and overlapped tall replies. */ import { useEffect, useState } from 'react'; -import { AlertTriangle, CircleDot, GitBranch, Plus, Sparkles, TerminalSquare } from 'lucide-react'; -import { DiffStat, EmptyState, IconButton, Spinner } from '@/renderer/components/ui'; +import { AlertTriangle, CircleDot, GitBranch, Plus, TerminalSquare } from 'lucide-react'; +import { DiffStat, IconButton, Spinner } from '@/renderer/components/ui'; import { useIsSessionRunning } from '@/renderer/features/sessions/useSessionRunning'; import { WorktreeTabs } from '@/renderer/features/sessions/WorktreeTabs'; import { ServicesStrip } from '@/renderer/features/sessions/ServicesStrip'; @@ -75,12 +75,18 @@ export function CenterWorkspace() { messageCount > 0 ? ( ) : ( - +
+ +
+ + Start the conversation + + + Describe what you want to build. Limboo coordinates the repository, + files, terminal, and tasks while Claude Code does the work. + +
+
) ) : (
diff --git a/src/renderer/features/workspace/Composer.tsx b/src/renderer/features/workspace/Composer.tsx index 299499b..adf0c06 100644 --- a/src/renderer/features/workspace/Composer.tsx +++ b/src/renderer/features/workspace/Composer.tsx @@ -26,6 +26,7 @@ import { useVoiceStore } from '@/renderer/stores/useVoiceStore'; import { useUIStore } from '@/renderer/stores/useUIStore'; import { useAttachmentStore, draftAttachments } from '@/renderer/stores/useAttachmentStore'; import { useFileDragActive } from '@/renderer/hooks/usePreventFileDrop'; +import { useTypewriter } from '@/renderer/hooks/useTypewriter'; import { lifecycleMeta, phaseLabel } from '@/renderer/features/agent/status'; import { RUNNING_PHASES } from '@/renderer/features/sessions/useSessionRunning'; import { ComposerControls } from './ComposerControls'; @@ -40,6 +41,25 @@ const VOICE_CAPTURE_PHASES = new Set(['starting', 'listening', 'recording', 'tra /** Max grow height before the editor scrolls internally (~40vh). */ const MAX_HEIGHT = 320; +/** Rotating placeholder prompts (build mode) — a typewriter hint, like the + * Global Search input, that idles through example asks until the user types. */ +const COMPOSER_PLACEHOLDERS = [ + 'Ask Claude Code to build something…', + 'Describe a feature to implement…', + 'Paste an error and ask for a fix…', + 'Refactor a file, add a test, wire an API…', + 'Explain how this codebase works…', + 'Draft a migration, then run it…', +]; + +/** Plan-mode variants — the run stays read-only and produces a plan first. */ +const PLAN_PLACEHOLDERS = [ + 'Describe what to build — Claude Code plans it first (read-only)…', + 'Outline a refactor to review before it runs…', + 'Scope a feature — get a step-by-step plan…', + 'Ask for a plan before any files change…', +]; + export function Composer({ disabled = false }: { disabled?: boolean }) { const [value, setValue] = useState(''); const ref = useRef(null); @@ -83,6 +103,17 @@ export function Composer({ disabled = false }: { disabled?: boolean }) { const restricted = lifecycle === 'rate-limited' || lifecycle === 'auth-required'; const blocked = disabled || !installed || busy || restricted; + // Rotating typewriter placeholder — only in the normal "ready to type" state; + // the special-state hints (not installed / restricted / disabled) stay static. + // Pauses the moment the user starts typing (like the Global Search input). + const normalPlaceholderState = installed && !disabled && !restricted; + const typedPlaceholder = useTypewriter(mode === 'plan' ? PLAN_PLACEHOLDERS : COMPOSER_PLACEHOLDERS, { + paused: !normalPlaceholderState || value.length > 0, + }); + const placeholder = normalPlaceholderState + ? typedPlaceholder + : composerPlaceholder(disabled, installed, restricted, mode); + // Attachments — ChatGPT-style file chips above the input. Drafts belong to // the SESSION (main-process Attachment Manager), so they survive reloads and // session switches until sent or removed. @@ -249,7 +280,7 @@ export function Composer({ disabled = false }: { disabled?: boolean }) { }} onKeyDown={onKeyDown} onPaste={onPaste} - placeholder={composerPlaceholder(disabled, installed, restricted, mode)} + placeholder={placeholder} className="flex-1 resize-none bg-transparent py-1 text-[13px] leading-relaxed text-fg placeholder:text-faint focus:outline-none disabled:cursor-not-allowed" style={{ maxHeight: MAX_HEIGHT }} /> diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index 79e52a6..67516c7 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -195,6 +195,31 @@ html[data-reduced-motion="true"] *::after { animation: limboo-caret-blink 1.1s step-end infinite; } +/* Self-drawing success checkmark (SuccessCheck): the ring draws first, then the + tick, via stroke-dashoffset. Fill-mode forwards keeps the drawn state; the + global reduced-motion rule collapses the duration so it renders instantly. */ +@keyframes limboo-check-circle { + from { stroke-dashoffset: 151; } + to { stroke-dashoffset: 0; } +} + +@keyframes limboo-check-tick { + from { stroke-dashoffset: 40; } + to { stroke-dashoffset: 0; } +} + +.success-check-circle { + stroke-dasharray: 151; + stroke-dashoffset: 151; + animation: limboo-check-circle 480ms ease-out forwards; +} + +.success-check-tick { + stroke-dasharray: 40; + stroke-dashoffset: 40; + animation: limboo-check-tick 300ms ease-out 380ms forwards; +} + /* ------------------------------------------------------------------ */ /* Markdown rendering (assistant messages) */ /* ------------------------------------------------------------------ */