Skip to content

Commit eb21feb

Browse files
committed
feat(dialogs): keyboard navigation for diff and plan viewer dialogs
- Add arrow/page/home/end key scrolling to both dialog viewers via shared createDialogScroll primitive - Make plan panel focusable with keyboard scrolling (arrow/page keys) - Enter on focused plan panel opens the plan viewer dialog - Size plan viewer dialog to fit content (was fixed 70vw) - Bump plan dialog font size from 15px to 17px
1 parent f745408 commit eb21feb

8 files changed

Lines changed: 80 additions & 143 deletions

File tree

electron/ipc/plans.test.ts

Lines changed: 0 additions & 97 deletions
This file was deleted.

electron/ipc/plans.ts

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ interface PlanWatcher {
99
pollTimer: ReturnType<typeof setInterval> | null;
1010
plansDirs: string[];
1111
watchedDirs: Set<string>;
12-
knownFiles: Set<string>;
1312
}
1413

1514
const watchers = new Map<string, PlanWatcher>();
@@ -20,25 +19,6 @@ const PLAN_DIRS = ['.claude/plans', 'docs/plans'];
2019
/** How often to check for newly created plan directories (ms). */
2120
const DIR_POLL_INTERVAL = 3_000;
2221

23-
/** Snapshot existing `.md` filenames across the given directories. */
24-
export function snapshotExistingFiles(dirs: string[]): Set<string> {
25-
const known = new Set<string>();
26-
for (const dir of dirs) {
27-
let entries: fs.Dirent[];
28-
try {
29-
entries = fs.readdirSync(dir, { withFileTypes: true });
30-
} catch {
31-
continue;
32-
}
33-
for (const e of entries) {
34-
if (e.isFile() && e.name.endsWith('.md')) {
35-
known.add(e.name);
36-
}
37-
}
38-
}
39-
return known;
40-
}
41-
4222
/**
4323
* Reads and merges `.claude/settings.local.json` in the worktree to set
4424
* `plansDirectory: "./.claude/plans"`. Creates the plans dir if needed.
@@ -71,20 +51,15 @@ export function ensurePlansDirectory(worktreePath: string): void {
7151
}
7252

7353
/** Reads the newest `.md` file by mtime from a single plans directory. */
74-
function readNewestPlan(
75-
plansDir: string,
76-
knownFiles?: Set<string>,
77-
): { content: string; fileName: string; mtime: number } | null {
54+
function readNewestPlan(plansDir: string): { content: string; fileName: string; mtime: number } | null {
7855
let entries: fs.Dirent[];
7956
try {
8057
entries = fs.readdirSync(plansDir, { withFileTypes: true });
8158
} catch {
8259
return null;
8360
}
8461

85-
const mdFiles = entries.filter(
86-
(e) => e.isFile() && e.name.endsWith('.md') && (!knownFiles || !knownFiles.has(e.name)),
87-
);
62+
const mdFiles = entries.filter((e) => e.isFile() && e.name.endsWith('.md'));
8863
if (mdFiles.length === 0) return null;
8964

9065
let newest: { name: string; mtime: number } | null = null;
@@ -111,13 +86,10 @@ function readNewestPlan(
11186
}
11287

11388
/** Reads the newest plan across multiple directories. */
114-
export function readNewestPlanFromDirs(
115-
plansDirs: string[],
116-
knownFiles?: Set<string>,
117-
): { content: string; fileName: string } | null {
89+
function readNewestPlanFromDirs(plansDirs: string[]): { content: string; fileName: string } | null {
11890
let best: { content: string; fileName: string; mtime: number } | null = null;
11991
for (const dir of plansDirs) {
120-
const result = readNewestPlan(dir, knownFiles);
92+
const result = readNewestPlan(dir);
12193
if (result && (!best || result.mtime > best.mtime)) {
12294
best = result;
12395
}
@@ -126,9 +98,9 @@ export function readNewestPlanFromDirs(
12698
}
12799

128100
/** Sends plan content for a task to the renderer. */
129-
function sendPlanContent(win: BrowserWindow, taskId: string, plansDirs: string[], knownFiles: Set<string>): void {
101+
function sendPlanContent(win: BrowserWindow, taskId: string, plansDirs: string[]): void {
130102
if (win.isDestroyed()) return;
131-
const result = readNewestPlanFromDirs(plansDirs, knownFiles);
103+
const result = readNewestPlanFromDirs(plansDirs);
132104
if (result) {
133105
win.webContents.send(IPC.PlanContent, {
134106
taskId,
@@ -170,10 +142,6 @@ function startDirPolling(taskId: string, entry: PlanWatcher, onChange: () => voi
170142
for (const dir of current.plansDirs) {
171143
if (current.watchedDirs.has(dir)) continue;
172144
if (!fs.existsSync(dir)) continue;
173-
// Snapshot existing files before watching so they are ignored
174-
for (const name of snapshotExistingFiles([dir])) {
175-
current.knownFiles.add(name);
176-
}
177145
const watcher = watchDir(dir, onChange);
178146
if (watcher) {
179147
current.fsWatchers.push(watcher);
@@ -206,15 +174,14 @@ export function startPlanWatcher(win: BrowserWindow, taskId: string, worktreePat
206174
const claudePlansDir = path.join(worktreePath, '.claude', 'plans');
207175
fs.mkdirSync(claudePlansDir, { recursive: true });
208176

209-
const knownFiles = snapshotExistingFiles(plansDirs);
177+
sendPlanContent(win, taskId, plansDirs);
210178

211179
const entry: PlanWatcher = {
212180
fsWatchers: [],
213181
timeout: null,
214182
pollTimer: null,
215183
plansDirs,
216184
watchedDirs: new Set(),
217-
knownFiles,
218185
};
219186

220187
const onChange = () => {
@@ -223,7 +190,7 @@ export function startPlanWatcher(win: BrowserWindow, taskId: string, worktreePat
223190
if (current.timeout) clearTimeout(current.timeout);
224191
current.timeout = setTimeout(() => {
225192
current.timeout = null;
226-
sendPlanContent(win, taskId, current.plansDirs, current.knownFiles);
193+
sendPlanContent(win, taskId, current.plansDirs);
227194
}, 200);
228195
};
229196

src/components/DiffViewerDialog.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Show, createSignal, createEffect, onCleanup } from 'solid-js';
22
import { Dialog } from './Dialog';
33
import { invoke } from '../lib/ipc';
44
import { IPC } from '../../electron/ipc/channels';
5+
import { createDialogScroll } from '../lib/dialog-scroll';
56
import { theme } from '../lib/theme';
67
import { sf } from '../lib/fontScale';
78
import { parseUnifiedDiff } from '../lib/unified-diff-parser';
@@ -86,6 +87,12 @@ function DiffViewerContent(props: DiffViewerDialogProps) {
8687

8788
let fetchGeneration = 0;
8889
let searchInputRef: HTMLInputElement | undefined;
90+
let diffScrollRef: HTMLDivElement | undefined;
91+
92+
createDialogScroll(
93+
() => diffScrollRef,
94+
() => props.scrollToFile !== null,
95+
);
8996

9097
// Ctrl+F / Cmd+F handler to focus the search input
9198
createEffect(() => {
@@ -305,6 +312,7 @@ function DiffViewerContent(props: DiffViewerDialogProps) {
305312
onAnnotationAdd={review.addAnnotation}
306313
onAnnotationDismiss={review.dismissAnnotation}
307314
scrollToAnnotation={review.scrollTarget()}
315+
onScrollRef={(el) => { diffScrollRef = el; }}
308316
/>
309317
</Show>
310318
</div>

src/components/PlanViewerDialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Show, For, createSignal, createEffect } from 'solid-js';
22
import { Dialog } from './Dialog';
3+
import { createDialogScroll } from '../lib/dialog-scroll';
34
import { ReviewProvider, useReview } from './ReviewProvider';
45
import { ReviewCommentsButton, ReviewSidebarPanel } from './ReviewSidebarPanel';
56
import { ReviewCommentCard } from './ReviewCommentCard';
@@ -39,9 +40,10 @@ export function PlanViewerDialog(props: PlanViewerDialogProps) {
3940
<Dialog
4041
open={props.open}
4142
onClose={props.onClose}
42-
width="70vw"
43+
width="fit-content"
4344
panelStyle={{
4445
height: '70vh',
46+
'min-width': '360px',
4547
'max-width': '1000px',
4648
overflow: 'hidden',
4749
padding: '0',
@@ -88,6 +90,11 @@ function PlanViewerContent(props: PlanViewerContentProps) {
8890
const [cardOffsets, setCardOffsets] = createSignal<Record<string, number>>({});
8991
const [highlightRects, setHighlightRects] = createSignal<HighlightRect[]>([]);
9092

93+
createDialogScroll(
94+
() => scrollRef,
95+
() => !!props.planContent,
96+
);
97+
9198
// Scroll to annotation when scrollTarget changes
9299
createEffect(() => {
93100
const target = review.scrollTarget();

src/components/ScrollingDiffView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface ScrollingDiffViewProps {
2424
onAnnotationAdd: (annotation: ReviewAnnotation) => void;
2525
onAnnotationDismiss: (id: string) => void;
2626
scrollToAnnotation?: ReviewAnnotation | null;
27+
onScrollRef?: (el: HTMLDivElement) => void;
2728
}
2829

2930
const STATUS_LABELS: Record<string, string> = {
@@ -754,7 +755,7 @@ export function ScrollingDiffView(props: ScrollingDiffViewProps) {
754755

755756
return (
756757
<div
757-
ref={containerRef}
758+
ref={(el) => { containerRef = el; props.onScrollRef?.(el); }}
758759
style={{
759760
height: '100%',
760761
'overflow-y': 'auto',

src/components/TaskPanel.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export function TaskPanel(props: TaskPanelProps) {
9090
let promptRef: HTMLTextAreaElement | undefined;
9191
let notesRef: HTMLTextAreaElement | undefined;
9292
let reviewPlanBtnRef: HTMLButtonElement | undefined;
93+
let planScrollRef: HTMLDivElement | undefined;
9394
let changedFilesRef: HTMLDivElement | undefined;
9495
let shellToolbarRef: HTMLDivElement | undefined;
9596
let titleEditHandle: EditableTextHandle | undefined;
@@ -108,7 +109,7 @@ export function TaskPanel(props: TaskPanelProps) {
108109
registerFocusFn(`${id}:title`, () => titleEditHandle?.startEdit());
109110
registerFocusFn(`${id}:notes`, () => {
110111
if (notesTab() === 'plan') {
111-
reviewPlanBtnRef?.focus();
112+
planScrollRef?.focus();
112113
} else {
113114
notesRef?.focus();
114115
}
@@ -604,6 +605,8 @@ export function TaskPanel(props: TaskPanelProps) {
604605
}}
605606
>
606607
<div
608+
ref={planScrollRef}
609+
tabIndex={0}
607610
class="plan-markdown"
608611
style={{
609612
flex: '1',
@@ -613,6 +616,20 @@ export function TaskPanel(props: TaskPanelProps) {
613616
color: theme.fg,
614617
'font-size': sf(11),
615618
'font-family': "'JetBrains Mono', monospace",
619+
outline: 'none',
620+
}}
621+
onKeyDown={(e) => {
622+
if (e.key === 'Enter') { e.preventDefault(); setPlanFullscreen(true); return; }
623+
const step = 40;
624+
const page = Math.max(100, planScrollRef!.clientHeight - 40);
625+
switch (e.key) {
626+
case 'ArrowDown': e.preventDefault(); planScrollRef!.scrollTop += step; break;
627+
case 'ArrowUp': e.preventDefault(); planScrollRef!.scrollTop -= step; break;
628+
case 'PageDown': e.preventDefault(); planScrollRef!.scrollTop += page; break;
629+
case 'PageUp': e.preventDefault(); planScrollRef!.scrollTop -= page; break;
630+
case 'Home': e.preventDefault(); planScrollRef!.scrollTop = 0; break;
631+
case 'End': e.preventDefault(); planScrollRef!.scrollTop = planScrollRef!.scrollHeight; break;
632+
}
616633
}}
617634
// eslint-disable-next-line solid/no-innerhtml -- plan files are local, written by Claude Code in the worktree
618635
innerHTML={planHtml()}

src/lib/dialog-scroll.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { createEffect, onCleanup } from 'solid-js';
2+
3+
/**
4+
* Reactive primitive that binds Arrow / Page / Home / End keys to scroll
5+
* a container element while a dialog is open. Skips events originating
6+
* from input elements so typing isn't affected.
7+
*/
8+
export function createDialogScroll(
9+
getScrollEl: () => HTMLElement | undefined,
10+
isActive: () => boolean,
11+
): void {
12+
createEffect(() => {
13+
if (!isActive()) return;
14+
const handler = (e: KeyboardEvent) => {
15+
const tag = (e.target as HTMLElement)?.tagName;
16+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
17+
const el = getScrollEl();
18+
if (!el) return;
19+
20+
const step = 40;
21+
const page = Math.max(100, el.clientHeight - 40);
22+
switch (e.key) {
23+
case 'ArrowDown': e.preventDefault(); el.scrollTop += step; break;
24+
case 'ArrowUp': e.preventDefault(); el.scrollTop -= step; break;
25+
case 'PageDown': e.preventDefault(); el.scrollTop += page; break;
26+
case 'PageUp': e.preventDefault(); el.scrollTop -= page; break;
27+
case 'Home': e.preventDefault(); el.scrollTop = 0; break;
28+
case 'End': e.preventDefault(); el.scrollTop = el.scrollHeight; break;
29+
}
30+
};
31+
document.addEventListener('keydown', handler);
32+
onCleanup(() => document.removeEventListener('keydown', handler));
33+
});
34+
}

src/styles.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1204,7 +1204,7 @@ body.dragging-task * {
12041204
.plan-markdown-dialog {
12051205
max-width: 720px;
12061206
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
1207-
font-size: 15px;
1207+
font-size: 17px;
12081208
line-height: 1.7;
12091209
}
12101210

@@ -1270,7 +1270,7 @@ body.dragging-task * {
12701270
/* Code blocks — keep monospace, more padding */
12711271
.plan-markdown-dialog pre {
12721272
font-family: 'JetBrains Mono', monospace;
1273-
font-size: 13px;
1273+
font-size: 14px;
12741274
line-height: 1.5;
12751275
margin: 0.8em 0;
12761276
}

0 commit comments

Comments
 (0)