Skip to content

Commit 53c694c

Browse files
committed
feat: M13 UX polish — collection search, table keyboard, auto-focus, dirty guard (#T1300-T1303,T1307)
Four parallel agents shipped five tasks in one pass. All typecheck + 226 tests green. T1300 — Collection sidebar search/filter New search input at top of sidebar. Real-time case-insensitive filter on request name + URL + method prefix ("GET /users" works). Folders auto-expand when filtering, hide when zero descendants match. Cmd+Shift+F focuses from anywhere, Cmd+F when sidebar has focus, ESC clears and returns focus to tree. Empty state with "Clear filter" button. Files: Sidebar.tsx, store.ts, App.tsx T1301 — Headers/Params: Shift+Enter inserts row below Shift+Enter in any Key or Value cell inserts a new empty row below and focuses the new Key cell. Works in both Headers and Params tabs. Uses requestAnimationFrame to focus after React commits. preventDefault stops accidental request send. Files: HeadersEditor.tsx, ParamsEditor.tsx, RequestBuilder.tsx, store.ts T1302 — Headers/Params: auto-append row on Tab from last row Tab from the Key cell of the last row (when non-empty) silently appends a new empty row. Browser Tab focus naturally lands on Value of the current row. If last row is already blank, no append. If autocomplete dropdown is open, Tab inserts the variable instead. Files: HeadersEditor.tsx, ParamsEditor.tsx, HighlightedInput.tsx T1303 — Cmd+N auto-focus URL input newTab() in the store now bumps focusUrlTick in the same set() call. RequestBuilder's existing useEffect on focusUrlTick fires focus+select on the URL ref. restoreHistoryEntry() and openRequest() do NOT bump the tick, so restored tabs never steal focus. Files: store.ts T1307 — Dirty-tab close guard unification + "don't ask again" New useDirtyTabGuard hook centralizes all close paths. Every trigger (middle-click, Cmd+W, close button, context menu Close/Close Others/ Close Right/Close All, Cmd+Shift+W, command palette) now goes through the guard. ConfirmDialog gains an optional "Don't ask again for this session" checkbox. Session-scoped useRef, resets on restart. Bug fixed: Cmd+W and command palette "Close tab" previously bypassed the dirty check entirely. Files: useDirtyTabGuard.tsx (new), TabBar.tsx, Dialog.tsx, App.tsx, commands.ts Infrastructure: - New developer-ux agent definition (.claude/agents/developer-ux.md) for keyboard/focus/guard/micro-interaction tasks - pre-push hook gains build:packages step before typecheck to prevent stale .d.ts errors
1 parent 05164bb commit 53c694c

13 files changed

Lines changed: 663 additions & 178 deletions

File tree

.claude/agents/developer-ux.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
---
2+
name: developer-ux
3+
description: Use for UX polish tasks — keyboard shortcuts, focus management, tab close guards, micro-interactions, dirty state handling, empty states, loading skeletons. Complements developer-ui which builds the components; this agent wires the interactions.
4+
tools: Read, Write, Edit, Glob, Grep, Bash
5+
model: sonnet
6+
---
7+
8+
You are the UX Developer for Scrapeman. developer-ui builds the components, you make them feel right.
9+
10+
## Your lane
11+
- `apps/desktop/src/renderer/` — hooks, Zustand store actions, keyboard handlers, focus management, dialog triggers
12+
- Tab lifecycle (open, close, switch, restore, dirty guard)
13+
- Keyboard shortcuts (registration, conflict resolution, platform-aware symbols)
14+
- Focus management (auto-focus on mount, trap in dialogs, restore on close)
15+
- Micro-interactions (hover states, transition timing, toast placement)
16+
- Empty states and loading skeletons
17+
- Accessibility (aria labels, screen reader announcements, reduced-motion)
18+
19+
## What you do NOT touch
20+
- HTTP core, auth, proxy logic (developer-core)
21+
- Complex new component trees from scratch (developer-ui builds those, you wire them)
22+
- Main-process IPC handlers (developer-core)
23+
- File format parsing (developer-core)
24+
25+
## Relationship with developer-ui
26+
You and developer-ui work the same directory but different concerns. Avoid editing the same file in the same session. Coordinate via task dependencies in `planning/tasks.yaml`:
27+
- If your task depends on a component developer-ui is building, wait for it or stub the interface.
28+
- If developer-ui's component needs keyboard handling, they leave a `// TODO(developer-ux): wire shortcut` comment and you pick it up.
29+
30+
## Task contract
31+
Every task has acceptance criteria in `planning/tasks.yaml`. Before coding:
32+
1. Read the task and its dependencies.
33+
2. Identify which existing components you are wiring (grep for them, read them, understand the state shape).
34+
3. If the component does not exist yet and your task depends on it, stop and report the dependency.
35+
36+
Before finishing:
37+
1. Every keyboard shortcut works on mac (Cmd) AND windows/linux (Ctrl).
38+
2. Focus is never lost after an interaction (close dialog → focus returns to trigger, close tab → focus moves to next tab).
39+
3. No flash of incorrect state (dirty indicator appears before the save completes, not after).
40+
4. Transitions are 150-200ms ease-out, never blocking.
41+
5. Test: can you complete the entire flow without touching the mouse?
42+
43+
## Operating principles
44+
1. **Keyboard first.** If it cannot be done from the keyboard, it is not done.
45+
2. **Consistent guards.** If one close path shows a confirmation, ALL close paths show it. No surprises.
46+
3. **Session memory.** "Don't ask again" preferences are session-scoped by default (reset on restart). Only persist to disk if the PM spec says so.
47+
4. **Platform parity.** Cmd on mac, Ctrl on win/linux. No shortcuts that conflict with OS defaults.
48+
5. **Code comments in English** regardless of chat language.
49+
50+
## Typical tasks for you
51+
- T1303 Cmd+N auto-focus URL input
52+
- T1307 Dirty-tab close guard unification + "don't ask again"
53+
- Keyboard shortcut registration and conflict resolution
54+
- Focus trap in modals/dialogs
55+
- Tab close, reorder, restore focus management
56+
- Toast/notification timing and stacking
57+
58+
## When invoked
59+
- State which task ID(s) you are working on.
60+
- Read the relevant component source first (you wire existing components, you rarely create new ones).
61+
- Implement, test the keyboard flow end-to-end, report what changed.
62+
63+
## Style
64+
Obsessed with feel. If the cursor lands in the wrong place after a shortcut, the task is not done.

apps/desktop/src/renderer/src/App.tsx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { usePlatform } from './hooks/usePlatform.js';
1616
import { useShortcuts, type Shortcut } from './hooks/useShortcuts.js';
1717
import { useTheme } from './hooks/useTheme.js';
1818
import { UpdateBanner } from './components/UpdateBanner.js';
19+
import { useDirtyTabGuard } from './hooks/useDirtyTabGuard.js';
1920

2021
export function App(): JSX.Element {
2122
const workspace = useAppStore((s) => s.workspace);
@@ -25,7 +26,6 @@ export function App(): JSX.Element {
2526
const loadEnvironments = useAppStore((s) => s.loadEnvironments);
2627
const recents = useAppStore((s) => s.recents);
2728
const newTab = useAppStore((s) => s.newTab);
28-
const closeTab = useAppStore((s) => s.closeTab);
2929
const duplicateTab = useAppStore((s) => s.duplicateTab);
3030
const activateTabByIndex = useAppStore((s) => s.activateTabByIndex);
3131
const reopenClosedTab = useAppStore((s) => s.reopenClosedTab);
@@ -34,10 +34,13 @@ export function App(): JSX.Element {
3434
const saveOrPrompt = useAppStore((s) => s.saveOrPrompt);
3535
const focusUrl = useAppStore((s) => s.focusUrl);
3636
const focusSearch = useAppStore((s) => s.focusSearch);
37+
const focusSidebarSearch = useAppStore((s) => s.focusSidebarSearch);
3738
const toggleHiddenRequest = useAppStore((s) => s.toggleHiddenRequest);
3839
const tabs = useAppStore((s) => s.tabs);
3940
const isRepo = useAppStore((s) => s.gitStatus?.isRepo === true);
4041

42+
const guard = useDirtyTabGuard();
43+
4144
useEffect(() => {
4245
void loadRecents();
4346
}, [loadRecents]);
@@ -81,14 +84,24 @@ export function App(): JSX.Element {
8184
{
8285
combo: 'mod+w',
8386
description: 'Close tab',
84-
handler: () => activeTabId && closeTab(activeTabId),
87+
handler: () => guard.requestCloseActive(),
88+
},
89+
{
90+
combo: 'mod+shift+w',
91+
description: 'Close all tabs',
92+
handler: () => guard.requestCloseAll(),
8593
},
8694
{
8795
combo: 'mod+d',
8896
description: 'Duplicate tab',
8997
handler: () => activeTabId && duplicateTab(activeTabId),
9098
},
9199
{ combo: 'mod+l', description: 'Focus URL bar', handler: () => focusUrl() },
100+
{
101+
combo: 'mod+shift+f',
102+
description: 'Focus collection search',
103+
handler: () => focusSidebarSearch(),
104+
},
92105
{
93106
combo: 'mod+f',
94107
description: 'Find in response',
@@ -142,14 +155,15 @@ export function App(): JSX.Element {
142155
],
143156
[
144157
newTab,
145-
closeTab,
158+
guard,
146159
duplicateTab,
147160
activateTabByIndex,
148161
reopenClosedTab,
149162
activeTabId,
150163
saveOrPrompt,
151164
focusUrl,
152165
focusSearch,
166+
focusSidebarSearch,
153167
toggleHiddenRequest,
154168
tabs,
155169
isRepo,
@@ -184,8 +198,10 @@ export function App(): JSX.Element {
184198
toggleTheme,
185199
toggleSplit: () =>
186200
setSplitOrientation((o) => (o === 'horizontal' ? 'vertical' : 'horizontal')),
201+
requestCloseActive: () => guard.requestCloseActive(),
202+
requestCloseAll: () => guard.requestCloseAll(),
187203
}),
188-
[toggleTheme],
204+
[toggleTheme, guard],
189205
);
190206
const commands = useCommands(commandExtras);
191207

@@ -276,7 +292,7 @@ export function App(): JSX.Element {
276292
second={
277293
<div className="flex h-full flex-col overflow-hidden">
278294
<UpdateBanner />
279-
<TabBar />
295+
<TabBar guard={guard} />
280296
<div className="flex-1 overflow-hidden">
281297
<SplitPane
282298
orientation={splitOrientation}

apps/desktop/src/renderer/src/commands.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ export interface Command {
1212
export interface CommandExtras {
1313
toggleTheme: () => void;
1414
toggleSplit: () => void;
15+
requestCloseActive: () => void;
16+
requestCloseAll: () => void;
1517
}
1618

1719
export function useCommands(extras: CommandExtras): Command[] {
1820
const send = useAppStore((s) => s.send);
1921
const saveOrPrompt = useAppStore((s) => s.saveOrPrompt);
2022
const newTab = useAppStore((s) => s.newTab);
21-
const closeTab = useAppStore((s) => s.closeTab);
2223
const duplicateTab = useAppStore((s) => s.duplicateTab);
2324
const activeTabId = useAppStore((s) => s.activeTabId);
2425
const focusUrl = useAppStore((s) => s.focusUrl);
@@ -68,9 +69,7 @@ export function useCommands(extras: CommandExtras): Command[] {
6869
title: 'Close tab',
6970
section: 'Tabs',
7071
shortcut: 'mod+w',
71-
run: () => {
72-
if (activeTabId) closeTab(activeTabId);
73-
},
72+
run: () => extras.requestCloseActive(),
7473
},
7574
{
7675
id: 'tab.duplicate',
@@ -121,7 +120,6 @@ export function useCommands(extras: CommandExtras): Command[] {
121120
send,
122121
saveOrPrompt,
123122
newTab,
124-
closeTab,
125123
duplicateTab,
126124
activeTabId,
127125
focusUrl,

apps/desktop/src/renderer/src/components/HeadersEditor.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,70 @@
1+
import { useCallback, useRef } from 'react';
12
import type { HeaderRow } from '../store.js';
23
import { HighlightedInput } from '../ui/HighlightedInput.js';
34
import { CellContextMenu } from '../ui/CellContextMenu.js';
45

56
export function HeadersEditor({
67
rows,
78
onAdd,
9+
onInsertAfter,
810
onUpdate,
911
onRemove,
1012
}: {
1113
rows: HeaderRow[];
1214
onAdd: () => void;
15+
/** Insert a new empty row below the given row id. Returns the new row id. */
16+
onInsertAfter: (afterId: string) => string;
1317
onUpdate: (id: string, patch: Partial<HeaderRow>) => void;
1418
onRemove: (id: string) => void;
1519
}): JSX.Element {
20+
// Refs map: rowId -> key input element, used to focus after insert.
21+
const keyRefs = useRef<Record<string, HTMLInputElement | null>>({});
22+
23+
const focusKeyCell = useCallback(
24+
(rowId: string) => {
25+
// Use requestAnimationFrame so the new row has been rendered first.
26+
requestAnimationFrame(() => {
27+
keyRefs.current[rowId]?.focus();
28+
});
29+
},
30+
[],
31+
);
32+
33+
const handleKeyKeyDown = useCallback(
34+
(e: React.KeyboardEvent<HTMLInputElement>, row: HeaderRow) => {
35+
// T1301: Shift+Enter → insert row below, focus its Key cell.
36+
if (e.key === 'Enter' && e.shiftKey) {
37+
e.preventDefault();
38+
const newId = onInsertAfter(row.id);
39+
focusKeyCell(newId);
40+
return;
41+
}
42+
// T1302: Tab from Key cell of last row when key is non-empty →
43+
// append new row. Focus stays on the Value cell (natural Tab target).
44+
if (e.key === 'Tab' && !e.shiftKey) {
45+
const isLastRow = rows[rows.length - 1]?.id === row.id;
46+
if (isLastRow && row.key.trim().length > 0) {
47+
// Append without preventing default so Tab still moves focus
48+
// to the Value cell of this row.
49+
onAdd();
50+
}
51+
}
52+
},
53+
[rows, onAdd, onInsertAfter, focusKeyCell],
54+
);
55+
56+
const handleValueKeyDown = useCallback(
57+
(e: React.KeyboardEvent<HTMLInputElement>, row: HeaderRow) => {
58+
// T1301: Shift+Enter → insert row below, focus its Key cell.
59+
if (e.key === 'Enter' && e.shiftKey) {
60+
e.preventDefault();
61+
const newId = onInsertAfter(row.id);
62+
focusKeyCell(newId);
63+
}
64+
},
65+
[onInsertAfter, focusKeyCell],
66+
);
67+
1668
return (
1769
<div className="flex flex-col">
1870
<div className="grid grid-cols-[32px_1fr_1.5fr_32px] items-center border-b border-line bg-bg-subtle px-3 text-[10px] font-semibold uppercase tracking-wider text-ink-4">
@@ -35,10 +87,12 @@ export function HeadersEditor({
3587
/>
3688
</div>
3789
<input
90+
ref={(el) => { keyRefs.current[row.id] = el; }}
3891
type="text"
3992
value={row.key}
4093
placeholder="Header"
4194
onChange={(e) => onUpdate(row.id, { key: e.target.value })}
95+
onKeyDown={(e) => handleKeyKeyDown(e, row)}
4296
className="h-8 bg-transparent pr-2 font-mono text-xs text-ink-1 outline-none placeholder:text-ink-4"
4397
/>
4498
<CellContextMenu
@@ -49,6 +103,7 @@ export function HeadersEditor({
49103
<HighlightedInput
50104
value={row.value}
51105
onChange={(e) => onUpdate(row.id, { value: e.target.value })}
106+
onKeyDown={(e) => handleValueKeyDown(e, row)}
52107
placeholder="Bearer {{token}}"
53108
variant="cell"
54109
/>

apps/desktop/src/renderer/src/components/ParamsEditor.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,70 @@
1+
import { useCallback, useRef } from 'react';
12
import type { ParamRow } from '../store.js';
23
import { HighlightedInput } from '../ui/HighlightedInput.js';
34
import { CellContextMenu } from '../ui/CellContextMenu.js';
45

56
export function ParamsEditor({
67
rows,
78
onAdd,
9+
onInsertAfter,
810
onUpdate,
911
onRemove,
1012
}: {
1113
rows: ParamRow[];
1214
onAdd: () => void;
15+
/** Insert a new empty row below the given row id. Returns the new row id. */
16+
onInsertAfter: (afterId: string) => string;
1317
onUpdate: (id: string, patch: Partial<ParamRow>) => void;
1418
onRemove: (id: string) => void;
1519
}): JSX.Element {
20+
// Refs map: rowId -> HTMLInputElement for key cell focus after insert.
21+
const keyRefs = useRef<Record<string, HTMLInputElement | null>>({});
22+
23+
const focusKeyCell = useCallback(
24+
(rowId: string) => {
25+
// Use requestAnimationFrame so the new row has been rendered first.
26+
requestAnimationFrame(() => {
27+
keyRefs.current[rowId]?.focus();
28+
});
29+
},
30+
[],
31+
);
32+
33+
const handleKeyKeyDown = useCallback(
34+
(e: React.KeyboardEvent<HTMLInputElement>, row: ParamRow) => {
35+
// T1301: Shift+Enter → insert row below, focus its Key cell.
36+
if (e.key === 'Enter' && e.shiftKey) {
37+
e.preventDefault();
38+
const newId = onInsertAfter(row.id);
39+
focusKeyCell(newId);
40+
return;
41+
}
42+
// T1302: Tab from Key cell of last row when key is non-empty →
43+
// append new row. Focus stays on the Value cell (natural Tab target).
44+
if (e.key === 'Tab' && !e.shiftKey) {
45+
const isLastRow = rows[rows.length - 1]?.id === row.id;
46+
if (isLastRow && row.key.trim().length > 0) {
47+
// Append without preventing default so Tab still moves focus
48+
// to the Value cell of this row.
49+
onAdd();
50+
}
51+
}
52+
},
53+
[rows, onAdd, onInsertAfter, focusKeyCell],
54+
);
55+
56+
const handleValueKeyDown = useCallback(
57+
(e: React.KeyboardEvent<HTMLInputElement>, row: ParamRow) => {
58+
// T1301: Shift+Enter → insert row below, focus its Key cell.
59+
if (e.key === 'Enter' && e.shiftKey) {
60+
e.preventDefault();
61+
const newId = onInsertAfter(row.id);
62+
focusKeyCell(newId);
63+
}
64+
},
65+
[onInsertAfter, focusKeyCell],
66+
);
67+
1668
return (
1769
<div className="flex flex-col">
1870
<div className="grid grid-cols-[32px_1fr_1.5fr_32px] items-center border-b border-line bg-bg-subtle px-3 text-[10px] font-semibold uppercase tracking-wider text-ink-4">
@@ -35,10 +87,12 @@ export function ParamsEditor({
3587
/>
3688
</div>
3789
<input
90+
ref={(el) => { keyRefs.current[row.id] = el; }}
3891
type="text"
3992
value={row.key}
4093
placeholder="key"
4194
onChange={(e) => onUpdate(row.id, { key: e.target.value })}
95+
onKeyDown={(e) => handleKeyKeyDown(e, row)}
4296
className="h-8 bg-transparent pr-2 font-mono text-xs text-ink-1 outline-none placeholder:text-ink-4"
4397
/>
4498
<CellContextMenu
@@ -49,6 +103,7 @@ export function ParamsEditor({
49103
<HighlightedInput
50104
value={row.value}
51105
onChange={(e) => onUpdate(row.id, { value: e.target.value })}
106+
onKeyDown={(e) => handleValueKeyDown(e, row)}
52107
placeholder="value or {{var}}"
53108
variant="cell"
54109
/>

0 commit comments

Comments
 (0)