Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" })}
/>
</div>

Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/components/WorkspaceFileTree.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("@tanstack/react-query")>();
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(
<WorkspaceFileTree cwd="/repo/project" resolvedTheme="light" />,
);

expect(markup).toContain("No files found.");
});
});
9 changes: 6 additions & 3 deletions apps/web/src/components/WorkspaceFileTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -307,7 +310,7 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop

return (
<div className="space-y-0.5">
{query.data?.entries.map((entry) => {
{entries.map((entry) => {
if (entry.kind === "directory") {
const isExpanded = props.expandedDirectories[entry.path] ?? false;
return (
Expand Down Expand Up @@ -343,7 +346,7 @@ const WorkspaceFileTreeDirectory = memo(function WorkspaceFileTreeDirectory(prop
/>
);
})}
{props.depth === 0 && query.data?.truncated ? (
{props.depth === 0 && truncated ? (
<div className="px-2 py-1 text-[10px] text-muted-foreground/55">
Workspace tree may be truncated for very large repos.
</div>
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -89,6 +93,8 @@ describe("MessagesTimeline", () => {
resolvedTheme="light"
timestampFormat="locale"
workspaceRoot={undefined}
shortcutGuides={EMPTY_SHORTCUT_GUIDES}
onOpenSettings={() => {}}
/>,
);

Expand Down Expand Up @@ -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(
<MessagesTimeline
hasMessages={false}
isWorking={false}
activeTurnInProgress={false}
activeTurnStartedAt={null}
scrollContainer={null}
timelineEntries={[]}
completionDividerBeforeEntryId={null}
completionSummary={null}
turnDiffSummaryByAssistantMessageId={new Map()}
nowIso="2026-03-17T19:12:30.000Z"
expandedWorkGroups={{}}
onToggleWorkGroup={() => {}}
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");
});
});
93 changes: 88 additions & 5 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -82,6 +84,8 @@ interface MessagesTimelineProps {
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
shortcutGuides: ChatShortcutGuide[];
onOpenSettings: () => void;
}

export const MessagesTimeline = memo(function MessagesTimeline({
Expand All @@ -106,6 +110,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
resolvedTheme,
timestampFormat,
workspaceRoot,
shortcutGuides,
onOpenSettings,
}: MessagesTimelineProps) {
const timelineRootRef = useRef<HTMLDivElement | null>(null);
const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
Expand Down Expand Up @@ -600,11 +606,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({

if (!hasMessages && !isWorking) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground/30">
Send a message to start the conversation.
</p>
</div>
<EmptyTimelineGuidance shortcutGuides={shortcutGuides} onOpenSettings={onOpenSettings} />
);
}

Expand Down Expand Up @@ -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 (
<div className="flex h-full items-center justify-center px-4 py-10 sm:px-6">
<div className="mx-auto flex w-full max-w-2xl flex-col items-center text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/55">
Hotkey tip
</p>
<div className="mt-4 space-y-4">
<div className="space-y-2">
<h3 className="text-2xl font-medium tracking-tight text-foreground sm:text-3xl">
{currentGuide?.title ?? "Start with a shortcut"}
</h3>
<p className="mx-auto max-w-xl text-sm leading-6 text-muted-foreground sm:text-[15px]">
{currentGuide?.description ??
"A few useful bindings will appear here while the thread is empty."}
</p>
</div>

<div className="flex flex-wrap justify-center gap-2">
{currentGuide?.shortcutLabels.length ? (
currentGuide.shortcutLabels.map((label) => (
<Badge
key={`${currentGuide.id}:${label}`}
variant="outline"
size="sm"
className="rounded-full border-border/70 bg-background/70 px-2.5 text-foreground"
>
{label}
</Badge>
))
) : (
<Badge
variant="outline"
size="sm"
className="rounded-full border-border/70 bg-background/70 px-2.5 text-foreground"
>
No shortcut assigned
</Badge>
)}
</div>

<div className="space-y-3">
<p className="text-xs leading-5 text-muted-foreground/70">
Edit shortcuts from Settings whenever you want to change the defaults.
</p>
<Button type="button" variant="outline" size="sm" onClick={onOpenSettings}>
Manage hotkeys
</Button>
</div>
</div>
</div>
</div>
);
}

type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
type TimelineMessage = Extract<TimelineEntry, { kind: "message" }>["message"];
type TimelineProposedPlan = Extract<TimelineEntry, { kind: "proposed-plan" }>["proposedPlan"];
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isTerminalToggleShortcut,
resolveShortcutCommand,
shortcutLabelForCommand,
shortcutLabelsForCommand,
terminalNavigationShortcutData,
type ShortcutEventLike,
} from "./keybindings";
Expand Down Expand Up @@ -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", () => {
Expand Down
23 changes: 19 additions & 4 deletions apps/web/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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(
Expand Down
Loading
Loading