Skip to content

Commit 4363ff3

Browse files
committed
Show empty chat hotkey guidance
- Replace the empty-thread placeholder with rotating shortcut tips - Add shared chat shortcut guidance and multi-binding labels - Harden workspace tree rendering against partial directory payloads
1 parent bfe2af6 commit 4363ff3

9 files changed

Lines changed: 265 additions & 13 deletions

apps/web/src/components/ChatView.browser.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1473,7 +1473,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
14731473

14741474
// The empty thread view and composer should still be visible.
14751475
await expect
1476-
.element(page.getByText("Send a message to start the conversation."))
1476+
.element(page.getByRole("button", { name: "Manage hotkeys" }))
14771477
.toBeInTheDocument();
14781478
await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument();
14791479
} finally {

apps/web/src/components/ChatView.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import { useTheme } from "../hooks/useTheme";
8787
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
8888
import BranchToolbar from "./BranchToolbar";
8989
import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
90+
import { buildChatShortcutGuides } from "~/lib/chatShortcutGuidance";
9091
import PlanSidebar from "./PlanSidebar";
9192
import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
9293
import { YouTubePlayerDrawer } from "./YouTubePlayer";
@@ -1228,6 +1229,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
12281229
() => shortcutLabelForCommand(keybindings, "diff.toggle"),
12291230
[keybindings],
12301231
);
1232+
const platform = typeof navigator !== "undefined" ? navigator.platform : "";
1233+
const chatShortcutGuides = useMemo(
1234+
() => buildChatShortcutGuides(keybindings, platform),
1235+
[keybindings, platform],
1236+
);
12311237
const onToggleDiff = useCallback(() => {
12321238
void navigate({
12331239
to: "/$threadId",
@@ -4173,6 +4179,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
41734179
resolvedTheme={resolvedTheme}
41744180
timestampFormat={timestampFormat}
41754181
workspaceRoot={activeProject?.cwd ?? undefined}
4182+
shortcutGuides={chatShortcutGuides}
4183+
onOpenSettings={() => void navigate({ to: "/settings" })}
41764184
/>
41774185
</div>
41784186

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { renderToStaticMarkup } from "react-dom/server";
2+
import { useQuery } from "@tanstack/react-query";
3+
import { beforeEach, describe, expect, it, vi } from "vitest";
4+
5+
vi.mock("@tanstack/react-query", async (importOriginal) => {
6+
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
7+
return {
8+
...actual,
9+
useQuery: vi.fn(),
10+
};
11+
});
12+
13+
const useQueryMock = vi.mocked(useQuery);
14+
15+
describe("WorkspaceFileTree", () => {
16+
beforeEach(() => {
17+
useQueryMock.mockReset();
18+
});
19+
20+
it("does not crash when the directory query returns a partial payload", async () => {
21+
useQueryMock.mockReturnValue({
22+
data: { truncated: false },
23+
isError: false,
24+
isLoading: false,
25+
error: null,
26+
} as never);
27+
28+
const { WorkspaceFileTree } = await import("./WorkspaceFileTree");
29+
const markup = renderToStaticMarkup(
30+
<WorkspaceFileTree cwd="/repo/project" resolvedTheme="light" />,
31+
);
32+
33+
expect(markup).toContain("No files found.");
34+
});
35+
});

apps/web/src/components/WorkspaceFileTree.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,10 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop
298298
);
299299
}
300300

301-
if ((query.data?.entries.length ?? 0) === 0) {
301+
const entries = query.data?.entries ?? [];
302+
const truncated = query.data?.truncated ?? false;
303+
304+
if (entries.length === 0) {
302305
if (props.directoryPath) {
303306
return null;
304307
}
@@ -307,7 +310,7 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop
307310

308311
return (
309312
<div className="space-y-0.5">
310-
{query.data?.entries.map((entry) => {
313+
{entries.map((entry) => {
311314
if (entry.kind === "directory") {
312315
const isExpanded = props.expandedDirectories[entry.path] ?? false;
313316
return (
@@ -343,7 +346,7 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop
343346
/>
344347
);
345348
})}
346-
{props.depth === 0 && query.data?.truncated ? (
349+
{props.depth === 0 && truncated ? (
347350
<div className="px-2 py-1 text-[10px] text-muted-foreground/55">
348351
Workspace tree may be truncated for very large repos.
349352
</div>

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { MessageId } from "@okcode/contracts";
22
import { renderToStaticMarkup } from "react-dom/server";
33
import { beforeAll, describe, expect, it, vi } from "vitest";
44

5+
import { buildChatShortcutGuides } from "~/lib/chatShortcutGuidance";
6+
57
function matchMedia() {
68
return {
79
matches: false,
@@ -42,6 +44,8 @@ beforeAll(() => {
4244
});
4345
});
4446

47+
const EMPTY_SHORTCUT_GUIDES = buildChatShortcutGuides([], "Win32");
48+
4549
describe("MessagesTimeline", () => {
4650
it("renders inline terminal labels with the composer chip UI", async () => {
4751
const { MessagesTimeline } = await import("./MessagesTimeline");
@@ -89,6 +93,8 @@ describe("MessagesTimeline", () => {
8993
resolvedTheme="light"
9094
timestampFormat="locale"
9195
workspaceRoot={undefined}
96+
shortcutGuides={EMPTY_SHORTCUT_GUIDES}
97+
onOpenSettings={() => {}}
9298
/>,
9399
);
94100

@@ -134,10 +140,47 @@ describe("MessagesTimeline", () => {
134140
resolvedTheme="light"
135141
timestampFormat="locale"
136142
workspaceRoot={undefined}
143+
shortcutGuides={EMPTY_SHORTCUT_GUIDES}
144+
onOpenSettings={() => {}}
137145
/>,
138146
);
139147

140148
expect(markup).toContain("Context compacted");
141149
expect(markup).toContain("Work log");
142150
});
151+
152+
it("renders shortcut guidance when the timeline is empty", async () => {
153+
const { MessagesTimeline } = await import("./MessagesTimeline");
154+
const markup = renderToStaticMarkup(
155+
<MessagesTimeline
156+
hasMessages={false}
157+
isWorking={false}
158+
activeTurnInProgress={false}
159+
activeTurnStartedAt={null}
160+
scrollContainer={null}
161+
timelineEntries={[]}
162+
completionDividerBeforeEntryId={null}
163+
completionSummary={null}
164+
turnDiffSummaryByAssistantMessageId={new Map()}
165+
nowIso="2026-03-17T19:12:30.000Z"
166+
expandedWorkGroups={{}}
167+
onToggleWorkGroup={() => {}}
168+
onOpenTurnDiff={() => {}}
169+
revertTurnCountByUserMessageId={new Map()}
170+
onRevertUserMessage={() => {}}
171+
isRevertingCheckpoint={false}
172+
onImageExpand={() => {}}
173+
markdownCwd={undefined}
174+
resolvedTheme="light"
175+
timestampFormat="locale"
176+
workspaceRoot={undefined}
177+
shortcutGuides={EMPTY_SHORTCUT_GUIDES}
178+
onOpenSettings={() => {}}
179+
/>,
180+
);
181+
182+
expect(markup).toContain("Hotkey tip");
183+
expect(markup).toContain("Manage hotkeys");
184+
expect(markup).toContain("No shortcut assigned");
185+
});
143186
});

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
ZapIcon,
3636
} from "lucide-react";
3737
import { Button } from "../ui/button";
38+
import { Badge } from "../ui/badge";
3839
import { clamp } from "effect/Number";
3940
import { estimateTimelineMessageHeight } from "../timelineHeight";
4041
import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
@@ -43,6 +44,7 @@ import { ChangedFilesTree } from "./ChangedFilesTree";
4344
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
4445
import { MessageCopyButton } from "./MessageCopyButton";
4546
import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
47+
import type { ChatShortcutGuide } from "~/lib/chatShortcutGuidance";
4648
import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
4749
import {
4850
deriveDisplayedUserMessageState,
@@ -82,6 +84,8 @@ interface MessagesTimelineProps {
8284
resolvedTheme: "light" | "dark";
8385
timestampFormat: TimestampFormat;
8486
workspaceRoot: string | undefined;
87+
shortcutGuides: ChatShortcutGuide[];
88+
onOpenSettings: () => void;
8589
}
8690

8791
export const MessagesTimeline = memo(function MessagesTimeline({
@@ -106,6 +110,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
106110
resolvedTheme,
107111
timestampFormat,
108112
workspaceRoot,
113+
shortcutGuides,
114+
onOpenSettings,
109115
}: MessagesTimelineProps) {
110116
const timelineRootRef = useRef<HTMLDivElement | null>(null);
111117
const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
@@ -600,11 +606,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
600606

601607
if (!hasMessages && !isWorking) {
602608
return (
603-
<div className="flex h-full items-center justify-center">
604-
<p className="text-sm text-muted-foreground/30">
605-
Send a message to start the conversation.
606-
</p>
607-
</div>
609+
<EmptyTimelineGuidance shortcutGuides={shortcutGuides} onOpenSettings={onOpenSettings} />
608610
);
609611
}
610612

@@ -642,6 +644,87 @@ export const MessagesTimeline = memo(function MessagesTimeline({
642644
);
643645
});
644646

647+
function EmptyTimelineGuidance({
648+
shortcutGuides,
649+
onOpenSettings,
650+
}: {
651+
shortcutGuides: ChatShortcutGuide[];
652+
onOpenSettings: () => void;
653+
}) {
654+
const [guideIndex, setGuideIndex] = useState(0);
655+
const guideCount = shortcutGuides.length;
656+
const currentGuide = guideCount > 0 ? shortcutGuides[guideIndex % guideCount] : undefined;
657+
658+
useEffect(() => {
659+
setGuideIndex(0);
660+
}, [shortcutGuides]);
661+
662+
useEffect(() => {
663+
if (shortcutGuides.length <= 1) return;
664+
665+
const interval = window.setInterval(() => {
666+
setGuideIndex((currentIndex) => (currentIndex + 1) % shortcutGuides.length);
667+
}, 12_000);
668+
669+
return () => {
670+
window.clearInterval(interval);
671+
};
672+
}, [shortcutGuides.length]);
673+
674+
return (
675+
<div className="flex h-full items-center justify-center px-4 py-10 sm:px-6">
676+
<div className="mx-auto flex w-full max-w-2xl flex-col items-center text-center">
677+
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/55">
678+
Hotkey tip
679+
</p>
680+
<div className="mt-4 space-y-4">
681+
<div className="space-y-2">
682+
<h3 className="text-2xl font-medium tracking-tight text-foreground sm:text-3xl">
683+
{currentGuide?.title ?? "Start with a shortcut"}
684+
</h3>
685+
<p className="mx-auto max-w-xl text-sm leading-6 text-muted-foreground sm:text-[15px]">
686+
{currentGuide?.description ??
687+
"A few useful bindings will appear here while the thread is empty."}
688+
</p>
689+
</div>
690+
691+
<div className="flex flex-wrap justify-center gap-2">
692+
{currentGuide?.shortcutLabels.length ? (
693+
currentGuide.shortcutLabels.map((label) => (
694+
<Badge
695+
key={`${currentGuide.id}:${label}`}
696+
variant="outline"
697+
size="sm"
698+
className="rounded-full border-border/70 bg-background/70 px-2.5 text-foreground"
699+
>
700+
{label}
701+
</Badge>
702+
))
703+
) : (
704+
<Badge
705+
variant="outline"
706+
size="sm"
707+
className="rounded-full border-border/70 bg-background/70 px-2.5 text-foreground"
708+
>
709+
No shortcut assigned
710+
</Badge>
711+
)}
712+
</div>
713+
714+
<div className="space-y-3">
715+
<p className="text-xs leading-5 text-muted-foreground/70">
716+
Edit shortcuts from Settings whenever you want to change the defaults.
717+
</p>
718+
<Button type="button" variant="outline" size="sm" onClick={onOpenSettings}>
719+
Manage hotkeys
720+
</Button>
721+
</div>
722+
</div>
723+
</div>
724+
</div>
725+
);
726+
}
727+
645728
type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
646729
type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
647730
type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"];

apps/web/src/keybindings.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
isTerminalToggleShortcut,
2020
resolveShortcutCommand,
2121
shortcutLabelForCommand,
22+
shortcutLabelsForCommand,
2223
terminalNavigationShortcutData,
2324
type ShortcutEventLike,
2425
} from "./keybindings";
@@ -271,6 +272,16 @@ describe("shortcutLabelForCommand", () => {
271272
"Ctrl+O",
272273
);
273274
});
275+
276+
it("returns every binding label in declaration order", () => {
277+
assert.deepStrictEqual(shortcutLabelsForCommand(DEFAULT_BINDINGS, "terminal.toggle", "Linux"), [
278+
"Ctrl+J",
279+
"Ctrl+`",
280+
]);
281+
assert.deepStrictEqual(shortcutLabelsForCommand(DEFAULT_BINDINGS, "chat.newLocal", "Linux"), [
282+
"Ctrl+Shift+N",
283+
]);
284+
});
274285
});
275286

276287
describe("chat/editor shortcuts", () => {

apps/web/src/keybindings.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,12 +158,27 @@ export function shortcutLabelForCommand(
158158
command: KeybindingCommand,
159159
platform = navigator.platform,
160160
): string | null {
161-
for (let index = keybindings.length - 1; index >= 0; index -= 1) {
162-
const binding = keybindings[index];
161+
const labels = shortcutLabelsForCommand(keybindings, command, platform);
162+
return labels[labels.length - 1] ?? null;
163+
}
164+
165+
export function shortcutLabelsForCommand(
166+
keybindings: ResolvedKeybindingsConfig,
167+
command: KeybindingCommand,
168+
platform = navigator.platform,
169+
): string[] {
170+
const labels: string[] = [];
171+
const seenLabels = new Set<string>();
172+
173+
for (const binding of keybindings) {
163174
if (!binding || binding.command !== command) continue;
164-
return formatShortcutLabel(binding.shortcut, platform);
175+
const label = formatShortcutLabel(binding.shortcut, platform);
176+
if (seenLabels.has(label)) continue;
177+
seenLabels.add(label);
178+
labels.push(label);
165179
}
166-
return null;
180+
181+
return labels;
167182
}
168183

169184
export function isTerminalToggleShortcut(

0 commit comments

Comments
 (0)