Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/screenshots/resumeupdate.PNG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions docs/architecture/subsystems/resume-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

<img src="../../../assets/screenshots/resumeupdate.PNG" alt="Resume banner under the session header, the Revalidating chip in the header, and the repository-delta dialog" width="920" />

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
Expand Down
37 changes: 37 additions & 0 deletions src/renderer/components/ui/SuccessCheck.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 52 52"
fill="none"
role="img"
aria-label="Completed"
className={`text-success ${className ?? ''}`}
>
<circle
cx="26"
cy="26"
r="24"
stroke="currentColor"
strokeWidth="2.5"
className="success-check-circle"
/>
<path
d="M15 27.5 L23 35 L38 18"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="success-check-tick"
/>
</svg>
);
}
1 change: 1 addition & 0 deletions src/renderer/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
18 changes: 17 additions & 1 deletion src/renderer/features/activity/PlanPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' && (
<div className="flex items-center gap-3 rounded-md border border-success/40 bg-success/10 px-3 py-3">
<SuccessCheck size={44} className="shrink-0" />
<div className="flex min-w-0 flex-col">
<span className="text-[13px] font-semibold text-success">Execution complete</span>
<span className="text-[11px] text-muted">
{hasProgress
? `All ${progress.total} task${progress.total === 1 ? '' : 's'} finished.`
: 'The agent finished this plan.'}
</span>
</div>
</div>
)}

{/* Task outline (or the flat checklist fallback) */}
{hasOutline ? (
<OutlineTree
Expand Down
71 changes: 69 additions & 2 deletions src/renderer/features/settings/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@
*/
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import type { AppSettings } from '@shared/types';
import { useUIStore } from '@/renderer/stores/useUIStore';
import { useSettingsStore } from '@/renderer/stores/useSettingsStore';
import { SETTINGS_CATALOG } from './catalog';
import { SettingsNav } from './SettingsNav';
import { SettingsHighlightContext } from './controls';
import { diffSettings } from './diffSettings';
import { UnsavedSettingsDialog } from './UnsavedSettingsDialog';

const DEFAULT_CATEGORY = 'general';

Expand All @@ -24,19 +28,72 @@ export function SettingsModal() {
const [highlight, setHighlight] = useState<string | null>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout> | 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<AppSettings | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [changes, setChanges] = useState<string[]>([]);

// 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]);

useEffect(() => () => {
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) => {
Expand All @@ -55,9 +112,10 @@ export function SettingsModal() {
const Panel = category.Panel;

return (
<>
<div
className="animate-fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-6"
onMouseDown={close}
onMouseDown={attemptClose}
>
<div
className="animate-pop-in flex h-[78vh] max-h-[640px] w-full max-w-3xl overflow-hidden rounded-md border border-line-strong bg-elevated shadow-2xl"
Expand All @@ -77,7 +135,7 @@ export function SettingsModal() {
<button
type="button"
aria-label="Close"
onClick={close}
onClick={attemptClose}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<X size={15} />
Expand All @@ -102,5 +160,14 @@ export function SettingsModal() {
</div>
</div>
</div>

{confirmOpen && (
<UnsavedSettingsDialog
changes={changes}
onKeepEditing={keepEditing}
onDiscard={discardChanges}
/>
)}
</>
);
}
93 changes: 93 additions & 0 deletions src/renderer/features/settings/UnsavedSettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="animate-fade-in fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-6"
onMouseDown={onKeepEditing}
>
<div
className="animate-pop-in flex max-h-[70vh] w-full max-w-md flex-col overflow-hidden rounded-md border border-line-strong bg-elevated shadow-2xl"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-line px-4 py-3">
<span className="flex items-center gap-2 text-[13px] font-semibold text-fg">
<AlertTriangle size={14} className="text-warning" />
Discard unsaved changes?
</span>
<button
type="button"
aria-label="Keep editing"
onClick={onKeepEditing}
className="flex h-7 w-7 items-center justify-center rounded-md text-muted transition-colors hover:bg-surface-2 hover:text-fg"
>
<X size={15} />
</button>
</div>

<div className="flex min-h-0 flex-col gap-2 overflow-y-auto p-4">
<p className="text-[12px] leading-relaxed text-muted">
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.
</p>
<div className="flex flex-col gap-0.5 rounded-md border border-line bg-surface-2 px-3 py-2">
{changes.map((path) => (
<div key={path} className="flex items-center gap-2 text-[12px] text-fg">
<span className="h-1 w-1 shrink-0 rounded-full bg-warning" />
<span className="min-w-0 flex-1 truncate">{humanizeSettingPath(path)}</span>
</div>
))}
</div>
</div>

<div className="flex items-center justify-end gap-2 border-t border-line px-4 py-3">
<button
type="button"
onClick={onKeepEditing}
className="rounded-md border border-line bg-surface-2 px-3 py-1.5 text-[12px] font-medium text-fg transition-colors hover:border-line-strong"
>
Keep editing
</button>
<button
type="button"
onClick={onDiscard}
className="rounded-md bg-danger px-3 py-1.5 text-[12px] font-semibold text-base transition-opacity hover:opacity-90"
>
Discard changes
</button>
</div>
</div>
</div>
);
}
54 changes: 54 additions & 0 deletions src/renderer/features/settings/diffSettings.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<T>(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(' › ');
}
22 changes: 14 additions & 8 deletions src/renderer/features/workspace/CenterWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,12 +75,18 @@ export function CenterWorkspace() {
messageCount > 0 ? (
<ConversationView sessionId={session.id} />
) : (
<EmptyState
className="m-auto"
icon={Sparkles}
title="Start the conversation"
description="Describe what you want to build. Limboo coordinates the repository, files, terminal, and tasks while Claude Code does the work."
/>
<div className="m-auto flex max-w-md flex-col items-center gap-5 py-16 text-center">
<Logo size={60} />
<div className="flex flex-col gap-1.5">
<span className="text-[16px] font-semibold tracking-tight text-fg">
Start the conversation
</span>
<span className="text-[13px] leading-relaxed text-muted">
Describe what you want to build. Limboo coordinates the repository,
files, terminal, and tasks while Claude Code does the work.
</span>
</div>
</div>
)
) : (
<div className="m-auto flex flex-col items-center gap-4 text-center">
Expand Down
Loading
Loading