Skip to content

Commit 71e4afb

Browse files
authored
fix(dexdex): enforce Esc-close and critical-input autofocus contracts (#268)
## Summary - add repository/app-level UX rules requiring Esc-close dialogs and autofocus on single critical-input forms - update DexDex docs contracts to include the same UX invariants - implement shared hooks (useEscapeToClose, useFocusOnShow) and apply them across DexDex dialog/form surfaces - add and extend tests for create-dialog Escape behavior and autofocus scenarios ## Testing - pnpm --filter dexdex test - (from apps/dexdex) pnpm test
1 parent 9c93c56 commit 71e4afb

12 files changed

Lines changed: 178 additions & 15 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,8 @@ enum DexDexComponent {
236236
### Frontend Design Rules
237237

238238
- Frontend work in `apps/` must follow Toss Design Guidelines for UX/UI decisions across web and mobile surfaces.
239+
- If a form has a single critical input, that input must receive focus when the form is shown.
240+
- Dialog UIs must support closing with the `Esc` key.
239241

240242
### Shell Command Safety Rules
241243

apps/AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- Keep repository and domain rules in the appropriate `AGENTS.md` files.
66
- Write all source and comments in English.
77
- Follow Toss Design Guidelines for frontend UX/UI decisions across web and mobile apps.
8+
- If a form has a single critical input, that input must receive focus when the form is shown.
9+
- Dialog UIs must support closing with the `Esc` key.
810

911
### Scope in This Domain
1012

@@ -54,6 +56,8 @@ enum DevkitMiniAppId {
5456
- Global shortcut question-handoff behavior (default binding, waiting-session routing, empty fallback) must remain aligned with DexDex app/server/proto contracts.
5557
- Menu bar tray behavior remains status-only unless docs explicitly expand scope; status derivation must use active-workspace contract semantics.
5658
- Session fork UX must keep parent-session immutability guarantees and remain limited to documented lifecycle actions.
59+
- Single critical-input forms in DexDex must auto-focus the input when shown.
60+
- DexDex dialog UIs must close with `Esc`.
5761

5862
### Multi-Component Contract Sync
5963

apps/dexdex/src/App.test.tsx

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from "@testing-library/react";
1+
import { render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33
import { describe, expect, it, beforeEach } from "vitest";
44
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -485,6 +485,22 @@ describe("App", () => {
485485
await screen.findByTestId("task-list");
486486
await user.click(screen.getByTestId("create-task-button"));
487487
expect(screen.getByTestId("create-dialog")).toBeTruthy();
488+
const promptInput = screen.getByTestId("task-prompt-input");
489+
await waitFor(() => {
490+
expect(document.activeElement).toBe(promptInput);
491+
});
492+
});
493+
494+
it("closes create task dialog with Escape", async () => {
495+
const user = userEvent.setup();
496+
renderWithProviders(<App />);
497+
498+
await screen.findByTestId("task-list");
499+
await user.click(screen.getByTestId("create-task-button"));
500+
expect(screen.getByTestId("create-dialog")).toBeTruthy();
501+
502+
await user.keyboard("{Escape}");
503+
expect(screen.queryByTestId("create-dialog")).toBeNull();
488504
});
489505

490506
it("creates a new task via dialog", async () => {
@@ -600,6 +616,34 @@ describe("App", () => {
600616
expect(screen.getByTestId("reject-button")).toBeTruthy();
601617
});
602618

619+
it("auto-focuses the revise input when revise mode opens", async () => {
620+
const user = userEvent.setup();
621+
renderWithProviders(<App />);
622+
623+
await screen.findByTestId("task-row-task-002");
624+
await user.click(screen.getByTestId("task-row-task-002"));
625+
await screen.findByTestId("plan-decisions");
626+
await user.click(screen.getByTestId("revise-button"));
627+
628+
const reviseInput = screen.getByTestId("revision-note-input");
629+
await waitFor(() => {
630+
expect(document.activeElement).toBe(reviseInput);
631+
});
632+
});
633+
634+
it("auto-focuses session input for waiting-for-input subtasks", async () => {
635+
const user = userEvent.setup();
636+
renderWithProviders(<App />);
637+
638+
await screen.findByTestId("task-row-task-004");
639+
await user.click(screen.getByTestId("task-row-task-004"));
640+
const sessionInput = await screen.findByTestId("session-input-textarea");
641+
642+
await waitFor(() => {
643+
expect(document.activeElement).toBe(sessionInput);
644+
});
645+
});
646+
603647
it("shows subtask timeline in task detail", async () => {
604648
const user = userEvent.setup();
605649
renderWithProviders(<App />);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { describe, expect, it, vi } from "vitest";
4+
import type { ReviewComment } from "../../lib/mock-data";
5+
import { InlineCommentThread } from "./inline-comment-thread";
6+
7+
const ACTIVE_COMMENT: ReviewComment = {
8+
reviewCommentId: "comment-1",
9+
body: "Please simplify this branch logic.",
10+
filePath: "src/app.ts",
11+
side: "RIGHT",
12+
lineNumber: 42,
13+
status: "ACTIVE",
14+
prTrackingId: "pr-1",
15+
createdAt: "2026-03-14T00:00:00Z",
16+
updatedAt: "2026-03-14T00:00:00Z",
17+
};
18+
19+
describe("InlineCommentThread", () => {
20+
it("auto-focuses reply textarea when reply editor opens", async () => {
21+
const user = userEvent.setup();
22+
render(
23+
<InlineCommentThread
24+
filePath={ACTIVE_COMMENT.filePath}
25+
lineNumber={ACTIVE_COMMENT.lineNumber}
26+
side={ACTIVE_COMMENT.side}
27+
comments={[ACTIVE_COMMENT]}
28+
onReply={vi.fn()}
29+
onResolve={vi.fn()}
30+
onReopen={vi.fn()}
31+
onDelete={vi.fn()}
32+
/>,
33+
);
34+
35+
await user.click(screen.getByRole("button", { name: "+ Reply" }));
36+
const replyInput = screen.getByPlaceholderText("Reply... (Cmd+Enter to submit)");
37+
38+
await waitFor(() => {
39+
expect(document.activeElement).toBe(replyInput);
40+
});
41+
});
42+
});

apps/dexdex/src/features/reviews/inline-comment-thread.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* Renders a thread of comments anchored to a specific file/line position.
44
*/
55

6-
import { type CSSProperties, useState } from "react";
6+
import { type CSSProperties, useRef, useState } from "react";
77
import type { ReviewComment } from "../../lib/mock-data";
8+
import { useFocusOnShow } from "../../hooks/use-dialog-accessibility";
89

910
interface InlineCommentThreadProps {
1011
filePath: string;
@@ -29,6 +30,9 @@ export function InlineCommentThread({
2930
}: InlineCommentThreadProps) {
3031
const [replyText, setReplyText] = useState("");
3132
const [showReply, setShowReply] = useState(false);
33+
const replyInputRef = useRef<HTMLTextAreaElement>(null);
34+
35+
useFocusOnShow(showReply, replyInputRef);
3236

3337
const isResolved = comments.length > 0 && comments[0].status === "RESOLVED";
3438

@@ -116,6 +120,7 @@ export function InlineCommentThread({
116120
{showReply ? (
117121
<div>
118122
<textarea
123+
ref={replyInputRef}
119124
value={replyText}
120125
onChange={(e) => setReplyText(e.target.value)}
121126
onKeyDown={handleKeyDown}

apps/dexdex/src/features/sessions/session-fork-panel.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
* Supports creating new forks, archiving active forks, and navigating to forked sessions.
44
*/
55

6-
import { type CSSProperties, useCallback, useState } from "react";
6+
import { type CSSProperties, useCallback, useRef, useState } from "react";
77
import {
88
useListForkedSessions,
99
useForkSessionMutation,
1010
useArchiveForkedSessionMutation,
1111
useListSessionCapabilities,
1212
} from "../../hooks/use-dexdex-queries";
13+
import { useEscapeToClose, useFocusOnShow } from "../../hooks/use-dialog-accessibility";
1314
import { toViewAgentCapability } from "../../lib/adapters";
1415
import { SessionForkStatus, SessionForkIntent, AgentSessionStatus } from "../../lib/status";
1516
import type { AgentCapability } from "../../lib/mock-data";
@@ -44,10 +45,19 @@ export function SessionForkPanel({ workspaceId, parentSessionId, onNavigateToSes
4445
const [showCreateDialog, setShowCreateDialog] = useState(false);
4546
const [forkIntent, setForkIntent] = useState<SessionForkIntent>(SessionForkIntent.EXPLORE_ALTERNATIVE);
4647
const [forkPrompt, setForkPrompt] = useState("");
48+
const forkPromptRef = useRef<HTMLTextAreaElement>(null);
4749

4850
const capabilities: AgentCapability[] = (capabilitiesQuery.data?.capabilities ?? []).map(toViewAgentCapability);
4951
const supportsFork = capabilities.some((c) => c.supportsFork);
5052

53+
const closeCreateDialog = useCallback(() => {
54+
setShowCreateDialog(false);
55+
setForkPrompt("");
56+
}, []);
57+
58+
useEscapeToClose(showCreateDialog, closeCreateDialog);
59+
useFocusOnShow(showCreateDialog, forkPromptRef);
60+
5161
const handleCreateFork = useCallback(() => {
5262
if (!forkPrompt.trim()) return;
5363
forkMutation.mutate(
@@ -59,13 +69,12 @@ export function SessionForkPanel({ workspaceId, parentSessionId, onNavigateToSes
5969
},
6070
{
6171
onSuccess: () => {
62-
setShowCreateDialog(false);
63-
setForkPrompt("");
72+
closeCreateDialog();
6473
setForkIntent(SessionForkIntent.EXPLORE_ALTERNATIVE);
6574
},
6675
},
6776
);
68-
}, [workspaceId, parentSessionId, forkIntent, forkPrompt, forkMutation]);
77+
}, [workspaceId, parentSessionId, forkIntent, forkPrompt, forkMutation, closeCreateDialog]);
6978

7079
const handleArchive = useCallback(
7180
(sessionId: string) => {
@@ -243,6 +252,7 @@ export function SessionForkPanel({ workspaceId, parentSessionId, onNavigateToSes
243252
))}
244253
</select>
245254
<textarea
255+
ref={forkPromptRef}
246256
value={forkPrompt}
247257
onChange={(e) => setForkPrompt(e.target.value)}
248258
placeholder="Describe what this fork should explore..."
@@ -253,10 +263,7 @@ export function SessionForkPanel({ workspaceId, parentSessionId, onNavigateToSes
253263
<div style={{ display: "flex", gap: "var(--space-2)", marginTop: "var(--space-2)", justifyContent: "flex-end" }}>
254264
<button
255265
style={buttonStyle}
256-
onClick={() => {
257-
setShowCreateDialog(false);
258-
setForkPrompt("");
259-
}}
266+
onClick={closeCreateDialog}
260267
data-testid="fork-cancel-button"
261268
>
262269
Cancel

apps/dexdex/src/features/sessions/session-input-form.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
* Supports Cmd/Ctrl+Enter to submit and Enter for newline.
44
*/
55

6-
import { type CSSProperties, useCallback, useState } from "react";
6+
import { type CSSProperties, useCallback, useRef, useState } from "react";
77
import { useSubmitSessionInputMutation } from "../../hooks/use-dexdex-queries";
8+
import { useFocusOnShow } from "../../hooks/use-dialog-accessibility";
89

910
interface SessionInputFormProps {
1011
workspaceId: string;
@@ -15,6 +16,9 @@ interface SessionInputFormProps {
1516
export function SessionInputForm({ workspaceId, sessionId, onSubmitted }: SessionInputFormProps) {
1617
const [inputText, setInputText] = useState("");
1718
const submitMutation = useSubmitSessionInputMutation();
19+
const inputRef = useRef<HTMLTextAreaElement>(null);
20+
21+
useFocusOnShow(true, inputRef);
1822

1923
const handleSubmit = useCallback(() => {
2024
if (!inputText.trim()) return;
@@ -96,6 +100,7 @@ export function SessionInputForm({ workspaceId, sessionId, onSubmitted }: Sessio
96100
<div style={containerStyle} data-testid="session-input-form">
97101
<label style={labelStyle}>Session Input</label>
98102
<textarea
103+
ref={inputRef}
99104
value={inputText}
100105
onChange={(e) => setInputText(e.target.value)}
101106
onKeyDown={handleKeyDown}

apps/dexdex/src/features/tasks/create-dialog.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
* Create task dialog component.
33
*/
44

5-
import { type CSSProperties, type FormEvent, useEffect, useMemo, useState } from "react";
5+
import { type CSSProperties, type FormEvent, useEffect, useMemo, useRef, useState } from "react";
66
import { AgentCliType } from "../../gen/v1/dexdex_pb";
77
import {
88
useGetWorkspaceSettings,
99
useListRepositoryGroups,
1010
useListSessionCapabilities,
1111
} from "../../hooks/use-dexdex-queries";
12+
import { useEscapeToClose, useFocusOnShow } from "../../hooks/use-dialog-accessibility";
1213

1314
interface CreateDialogProps {
1415
isOpen: boolean;
@@ -34,6 +35,7 @@ export function CreateDialog({ isOpen, workspaceId, onClose, onCreate }: CreateD
3435
const [selectedRepoGroupId, setSelectedRepoGroupId] = useState("");
3536
const [selectedAgent, setSelectedAgent] = useState<AgentCliType>(AgentCliType.UNSPECIFIED);
3637
const [usePlanMode, setUsePlanMode] = useState(false);
38+
const promptInputRef = useRef<HTMLTextAreaElement>(null);
3739

3840
const repoGroupsQuery = useListRepositoryGroups(workspaceId);
3941
const capabilitiesQuery = useListSessionCapabilities(workspaceId);
@@ -78,6 +80,9 @@ export function CreateDialog({ isOpen, workspaceId, onClose, onCreate }: CreateD
7880
}
7981
}, [selectedAgentSupportsPlanMode, usePlanMode]);
8082

83+
useEscapeToClose(isOpen, onClose);
84+
useFocusOnShow(isOpen, promptInputRef);
85+
8186
if (!isOpen) return null;
8287

8388
const canSubmit = prompt.trim().length > 0 && selectedRepoGroupId.trim().length > 0 && selectedAgent !== AgentCliType.UNSPECIFIED;
@@ -160,11 +165,11 @@ export function CreateDialog({ isOpen, workspaceId, onClose, onCreate }: CreateD
160165
</label>
161166
<textarea
162167
id="task-prompt"
168+
ref={promptInputRef}
163169
style={textareaStyle}
164170
value={prompt}
165171
onChange={(e) => setPrompt(e.target.value)}
166172
placeholder="Describe exactly what the coding agent should do..."
167-
autoFocus
168173
data-testid="task-prompt-input"
169174
/>
170175
</div>

apps/dexdex/src/features/tasks/plan-decisions.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
* Plan decision controls for subtasks waiting for plan approval.
33
*/
44

5-
import { type CSSProperties, useState } from "react";
5+
import { type CSSProperties, useRef, useState } from "react";
66
import { PlanDecision, SubTaskStatus } from "../../lib/status";
77
import type { SubTask } from "../../lib/mock-data";
8+
import { useFocusOnShow } from "../../hooks/use-dialog-accessibility";
89

910
interface PlanDecisionsProps {
1011
subtask: SubTask;
@@ -14,6 +15,9 @@ interface PlanDecisionsProps {
1415
export function PlanDecisions({ subtask, onDecision }: PlanDecisionsProps) {
1516
const [revisionNote, setRevisionNote] = useState("");
1617
const [showReviseInput, setShowReviseInput] = useState(false);
18+
const revisionInputRef = useRef<HTMLTextAreaElement>(null);
19+
20+
useFocusOnShow(showReviseInput, revisionInputRef);
1721

1822
if (subtask.status !== SubTaskStatus.WAITING_FOR_PLAN_APPROVAL) {
1923
return null;
@@ -69,6 +73,7 @@ export function PlanDecisions({ subtask, onDecision }: PlanDecisionsProps) {
6973
{showReviseInput && (
7074
<div style={{ marginTop: "var(--space-2)" }}>
7175
<textarea
76+
ref={revisionInputRef}
7277
style={{
7378
width: "100%",
7479
padding: "var(--space-2)",
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { type RefObject, useEffect } from "react";
2+
3+
export function useEscapeToClose(enabled: boolean, onClose: () => void): void {
4+
useEffect(() => {
5+
if (!enabled) return;
6+
7+
const handleKeyDown = (event: KeyboardEvent) => {
8+
if (event.key !== "Escape") {
9+
return;
10+
}
11+
12+
event.preventDefault();
13+
event.stopPropagation();
14+
onClose();
15+
};
16+
17+
document.addEventListener("keydown", handleKeyDown);
18+
return () => {
19+
document.removeEventListener("keydown", handleKeyDown);
20+
};
21+
}, [enabled, onClose]);
22+
}
23+
24+
export function useFocusOnShow<TElement extends HTMLElement>(
25+
enabled: boolean,
26+
elementRef: RefObject<TElement | null>,
27+
): void {
28+
useEffect(() => {
29+
if (!enabled) return;
30+
31+
const frameId = requestAnimationFrame(() => {
32+
elementRef.current?.focus();
33+
});
34+
35+
return () => {
36+
cancelAnimationFrame(frameId);
37+
};
38+
}, [enabled, elementRef]);
39+
}

0 commit comments

Comments
 (0)