From 4363ff3dd60283ecf53fd8439a5be900b6a21feb Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 29 Mar 2026 01:54:06 -0500 Subject: [PATCH] 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 --- apps/web/src/components/ChatView.browser.tsx | 2 +- apps/web/src/components/ChatView.tsx | 8 ++ .../src/components/WorkspaceFileTree.test.tsx | 35 +++++++ apps/web/src/components/WorkspaceFileTree.tsx | 9 +- .../components/chat/MessagesTimeline.test.tsx | 43 +++++++++ .../src/components/chat/MessagesTimeline.tsx | 93 ++++++++++++++++++- apps/web/src/keybindings.test.ts | 11 +++ apps/web/src/keybindings.ts | 23 ++++- apps/web/src/lib/chatShortcutGuidance.ts | 54 +++++++++++ 9 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/WorkspaceFileTree.test.tsx create mode 100644 apps/web/src/lib/chatShortcutGuidance.ts diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 83910d27c..a25018cac 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1473,7 +1473,7 @@ describe("ChatView timeline estimator parity (full app)", () => { // The empty thread view and composer should still be visible. await expect - .element(page.getByText("Send a message to start the conversation.")) + .element(page.getByRole("button", { name: "Manage hotkeys" })) .toBeInTheDocument(); await expect.element(page.getByTestId("composer-editor")).toBeInTheDocument(); } finally { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a1fd31fc4..ce49e6a2e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -87,6 +87,7 @@ import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { buildChatShortcutGuides } from "~/lib/chatShortcutGuidance"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { YouTubePlayerDrawer } from "./YouTubePlayer"; @@ -1228,6 +1229,11 @@ export default function ChatView({ threadId }: ChatViewProps) { () => shortcutLabelForCommand(keybindings, "diff.toggle"), [keybindings], ); + const platform = typeof navigator !== "undefined" ? navigator.platform : ""; + const chatShortcutGuides = useMemo( + () => buildChatShortcutGuides(keybindings, platform), + [keybindings, platform], + ); const onToggleDiff = useCallback(() => { void navigate({ to: "/$threadId", @@ -4173,6 +4179,8 @@ export default function ChatView({ threadId }: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} + shortcutGuides={chatShortcutGuides} + onOpenSettings={() => void navigate({ to: "/settings" })} /> diff --git a/apps/web/src/components/WorkspaceFileTree.test.tsx b/apps/web/src/components/WorkspaceFileTree.test.tsx new file mode 100644 index 000000000..16fa7d562 --- /dev/null +++ b/apps/web/src/components/WorkspaceFileTree.test.tsx @@ -0,0 +1,35 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { useQuery } from "@tanstack/react-query"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useQuery: vi.fn(), + }; +}); + +const useQueryMock = vi.mocked(useQuery); + +describe("WorkspaceFileTree", () => { + beforeEach(() => { + useQueryMock.mockReset(); + }); + + it("does not crash when the directory query returns a partial payload", async () => { + useQueryMock.mockReturnValue({ + data: { truncated: false }, + isError: false, + isLoading: false, + error: null, + } as never); + + const { WorkspaceFileTree } = await import("./WorkspaceFileTree"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("No files found."); + }); +}); diff --git a/apps/web/src/components/WorkspaceFileTree.tsx b/apps/web/src/components/WorkspaceFileTree.tsx index 6c820ff0e..45a12c9cf 100644 --- a/apps/web/src/components/WorkspaceFileTree.tsx +++ b/apps/web/src/components/WorkspaceFileTree.tsx @@ -298,7 +298,10 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop ); } - if ((query.data?.entries.length ?? 0) === 0) { + const entries = query.data?.entries ?? []; + const truncated = query.data?.truncated ?? false; + + if (entries.length === 0) { if (props.directoryPath) { return null; } @@ -307,7 +310,7 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop return (
- {query.data?.entries.map((entry) => { + {entries.map((entry) => { if (entry.kind === "directory") { const isExpanded = props.expandedDirectories[entry.path] ?? false; return ( @@ -343,7 +346,7 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop /> ); })} - {props.depth === 0 && query.data?.truncated ? ( + {props.depth === 0 && truncated ? (
Workspace tree may be truncated for very large repos.
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0dae17a1e..86ed2c8ea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2,6 +2,8 @@ import { MessageId } from "@okcode/contracts"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; +import { buildChatShortcutGuides } from "~/lib/chatShortcutGuidance"; + function matchMedia() { return { matches: false, @@ -42,6 +44,8 @@ beforeAll(() => { }); }); +const EMPTY_SHORTCUT_GUIDES = buildChatShortcutGuides([], "Win32"); + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); @@ -89,6 +93,8 @@ describe("MessagesTimeline", () => { resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} + shortcutGuides={EMPTY_SHORTCUT_GUIDES} + onOpenSettings={() => {}} />, ); @@ -134,10 +140,47 @@ describe("MessagesTimeline", () => { resolvedTheme="light" timestampFormat="locale" workspaceRoot={undefined} + shortcutGuides={EMPTY_SHORTCUT_GUIDES} + onOpenSettings={() => {}} />, ); expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("renders shortcut guidance when the timeline is empty", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + shortcutGuides={EMPTY_SHORTCUT_GUIDES} + onOpenSettings={() => {}} + />, + ); + + expect(markup).toContain("Hotkey tip"); + expect(markup).toContain("Manage hotkeys"); + expect(markup).toContain("No shortcut assigned"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5254eb5a9..e41dea88e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -35,6 +35,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { Badge } from "../ui/badge"; import { clamp } from "effect/Number"; import { estimateTimelineMessageHeight } from "../timelineHeight"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; @@ -43,6 +44,7 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import type { ChatShortcutGuide } from "~/lib/chatShortcutGuidance"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { deriveDisplayedUserMessageState, @@ -82,6 +84,8 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; + shortcutGuides: ChatShortcutGuide[]; + onOpenSettings: () => void; } export const MessagesTimeline = memo(function MessagesTimeline({ @@ -106,6 +110,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ resolvedTheme, timestampFormat, workspaceRoot, + shortcutGuides, + onOpenSettings, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); @@ -600,11 +606,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (!hasMessages && !isWorking) { return ( -
-

- Send a message to start the conversation. -

-
+ ); } @@ -642,6 +644,87 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }); +function EmptyTimelineGuidance({ + shortcutGuides, + onOpenSettings, +}: { + shortcutGuides: ChatShortcutGuide[]; + onOpenSettings: () => void; +}) { + const [guideIndex, setGuideIndex] = useState(0); + const guideCount = shortcutGuides.length; + const currentGuide = guideCount > 0 ? shortcutGuides[guideIndex % guideCount] : undefined; + + useEffect(() => { + setGuideIndex(0); + }, [shortcutGuides]); + + useEffect(() => { + if (shortcutGuides.length <= 1) return; + + const interval = window.setInterval(() => { + setGuideIndex((currentIndex) => (currentIndex + 1) % shortcutGuides.length); + }, 12_000); + + return () => { + window.clearInterval(interval); + }; + }, [shortcutGuides.length]); + + return ( +
+
+

+ Hotkey tip +

+
+
+

+ {currentGuide?.title ?? "Start with a shortcut"} +

+

+ {currentGuide?.description ?? + "A few useful bindings will appear here while the thread is empty."} +

+
+ +
+ {currentGuide?.shortcutLabels.length ? ( + currentGuide.shortcutLabels.map((label) => ( + + {label} + + )) + ) : ( + + No shortcut assigned + + )} +
+ +
+

+ Edit shortcuts from Settings whenever you want to change the defaults. +

+ +
+
+
+
+ ); +} + type TimelineEntry = ReturnType[number]; type TimelineMessage = Extract["message"]; type TimelineProposedPlan = Extract["proposedPlan"]; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 91656efea..619446a08 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -19,6 +19,7 @@ import { isTerminalToggleShortcut, resolveShortcutCommand, shortcutLabelForCommand, + shortcutLabelsForCommand, terminalNavigationShortcutData, type ShortcutEventLike, } from "./keybindings"; @@ -271,6 +272,16 @@ describe("shortcutLabelForCommand", () => { "Ctrl+O", ); }); + + it("returns every binding label in declaration order", () => { + assert.deepStrictEqual(shortcutLabelsForCommand(DEFAULT_BINDINGS, "terminal.toggle", "Linux"), [ + "Ctrl+J", + "Ctrl+`", + ]); + assert.deepStrictEqual(shortcutLabelsForCommand(DEFAULT_BINDINGS, "chat.newLocal", "Linux"), [ + "Ctrl+Shift+N", + ]); + }); }); describe("chat/editor shortcuts", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 0f9b84d48..aefe7c793 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -158,12 +158,27 @@ export function shortcutLabelForCommand( command: KeybindingCommand, platform = navigator.platform, ): string | null { - for (let index = keybindings.length - 1; index >= 0; index -= 1) { - const binding = keybindings[index]; + const labels = shortcutLabelsForCommand(keybindings, command, platform); + return labels[labels.length - 1] ?? null; +} + +export function shortcutLabelsForCommand( + keybindings: ResolvedKeybindingsConfig, + command: KeybindingCommand, + platform = navigator.platform, +): string[] { + const labels: string[] = []; + const seenLabels = new Set(); + + for (const binding of keybindings) { if (!binding || binding.command !== command) continue; - return formatShortcutLabel(binding.shortcut, platform); + const label = formatShortcutLabel(binding.shortcut, platform); + if (seenLabels.has(label)) continue; + seenLabels.add(label); + labels.push(label); } - return null; + + return labels; } export function isTerminalToggleShortcut( diff --git a/apps/web/src/lib/chatShortcutGuidance.ts b/apps/web/src/lib/chatShortcutGuidance.ts new file mode 100644 index 000000000..c6a613b69 --- /dev/null +++ b/apps/web/src/lib/chatShortcutGuidance.ts @@ -0,0 +1,54 @@ +import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@okcode/contracts"; + +import { shortcutLabelsForCommand } from "~/keybindings"; + +export interface ChatShortcutGuideDefinition { + id: string; + command: KeybindingCommand; + title: string; + description: string; +} + +export interface ChatShortcutGuide extends ChatShortcutGuideDefinition { + shortcutLabels: string[]; +} + +export const CHAT_SHORTCUT_GUIDE_DEFINITIONS = [ + { + id: "new-thread", + command: "chat.new", + title: "Start a new thread", + description: "Open a fresh conversation without leaving the current project.", + }, + { + id: "new-local-thread", + command: "chat.newLocal", + title: "Start a local thread", + description: "Create a thread in a local or worktree-backed environment.", + }, + { + id: "toggle-terminal", + command: "terminal.toggle", + title: "Open the terminal", + description: "Keep the shell one shortcut away while you chat.", + }, + { + id: "favorite-editor", + command: "editor.openFavorite", + title: "Open your favorite editor", + description: "Jump straight to the editor you last used on this project.", + }, +] as const satisfies readonly ChatShortcutGuideDefinition[]; + +export function buildChatShortcutGuides( + keybindings: ResolvedKeybindingsConfig, + platform: string, +): ChatShortcutGuide[] { + return CHAT_SHORTCUT_GUIDE_DEFINITIONS.map((definition) => ({ + id: definition.id, + command: definition.command, + title: definition.title, + description: definition.description, + shortcutLabels: shortcutLabelsForCommand(keybindings, definition.command, platform), + })); +}