Skip to content
Open
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
73 changes: 73 additions & 0 deletions apps/web/src/components/Sidebar.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
DEFAULT_CLIENT_SETTINGS,
Expand All @@ -12,6 +13,7 @@ import { userEvent } from "vite-plus/test/browser";

import { act, fakeTimers, render } from "../../test/render";
import { openCommandPalette } from "../commandPaletteBus";
import { DraftId, useComposerDraftStore } from "../composerDraftStore";
import { makeThreadFixture } from "../test-fixtures";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings";
Expand Down Expand Up @@ -112,6 +114,77 @@ describe("sidebar header new thread", () => {
});
});

// regression: unsent drafts were listed above threads but skipped by mod+1..9.
describe("sidebar numbered jumps", () => {
it("number unsent drafts from the top, keep the open draft's number, and open them", async () => {
const initial = useComposerDraftStore.getState();
using stack = new DisposableStack();
stack.defer(() => useComposerDraftStore.setState(initial, true));
const previousSettings = getClientSettings();
stack.defer(() => __setClientSettingsForTests(previousSettings));
__setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("Linux x86_64");
const web = project("web");
vi.mocked(useProjects).mockReturnValue([web]);
vi.mocked(useHandleNewThread).mockReturnValue({
activeDraftThread: null,
activeThread: null,
defaultProjectRef: null,
defaultProjectFallbackReady: true,
handleNewThread: vi.fn(),
routeDraftId: null,
routeThreadRef: null,
});
const projectRef = scopeProjectRef(web.environmentId, web.id);
const older = DraftId.make("older-draft");
const newer = DraftId.make("newer-draft");
// The newer draft is open; the test router has no draft route to read it from.
vi.mocked(useThreadRouteTarget).mockReturnValue({ kind: "draft", draftId: newer });
stack.defer(() => vi.mocked(useThreadRouteTarget).mockRestore());
useComposerDraftStore.setState({
draftsByThreadKey: {},
draftThreadsByThreadKey: {},
logicalProjectDraftThreadKeyByLogicalProjectKey: {},
});
for (const [draftId, createdAt] of [
[older, "2026-01-01T00:00:00.000Z"],
[newer, "2026-01-02T00:00:00.000Z"],
] as const) {
initial.setProjectDraftThreadId(projectRef, draftId, {
threadId: ThreadId.make(`${draftId}-thread`),
createdAt,
});
initial.setPrompt(draftId, `${draftId} prompt`);
}

await using screen = await render(
<SidebarProvider>
<Sidebar routeReveal={{ observedKey: null, pendingKey: null }} />
</SidebarProvider>,
);
const rows = screen.getByTestId("sidebar-draft-row");
await expect.element(rows.nth(1)).toHaveAccessibleName("older-draft prompt, Unsent draft, web");
await userEvent.keyboard("{Control>}");
await expect.element(rows.nth(0).getByText("Ctrl+1")).toBeVisible();
await expect.element(rows.nth(1).getByText("Ctrl+2")).toBeVisible();
await userEvent.keyboard("{/Control}");

// Clearing the open draft keeps its row and number, even after search hides the list.
await act(() => initial.setPrompt(newer, ""));
const search = screen.getByRole("combobox", { name: "Search threads" });
await userEvent.fill(search, "older");
await expect.element(rows.first()).not.toBeInTheDocument();
await userEvent.clear(search);
await expect.element(rows.nth(0)).toHaveAccessibleName("newer-draft prompt, Unsent draft, web");
await userEvent.keyboard("{Control>}");
await expect.element(rows.nth(0).getByText("Ctrl+1")).toBeVisible();
await expect.element(rows.nth(1).getByText("Ctrl+2")).toBeVisible();
await userEvent.keyboard("2{/Control}");

await vi.waitFor(() => expect(screen.router!.state.location.pathname).toBe(`/draft/${older}`));
});
});

function threadShell(projectId: ProjectId, index: number): EnvironmentThreadShell {
return makeThreadFixture({
environmentId: EnvironmentId.make("local"),
Expand Down
30 changes: 26 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ import {
type SidebarProviderEntry,
type SidebarSweepAction,
} from "./sidebar/SidebarThreadRow";
import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock";
import { SidebarDraftBlock, useSidebarDrafts } from "./sidebar/SidebarDraftBlock";
import {
SidebarProjectFolderHeader,
sidebarFolderRowId,
Expand Down Expand Up @@ -317,6 +317,7 @@ const sidebarProviderEntriesAtom = Atom.make((get) => {
}).pipe(Atom.withLabel("sidebar-provider-entries"));

const EMPTY_PROVIDER_ENTRIES: ReadonlyMap<string, SidebarProviderEntry> = new Map();
const EMPTY_DRAFT_IDS: readonly DraftId[] = [];
const EMPTY_THREADS: readonly EnvironmentThreadShell[] = [];

const SETTLED_TAIL_INITIAL_COUNT = 10;
Expand Down Expand Up @@ -1418,10 +1419,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const settledThreadKeysRef = useRef(settledThreadKeys);
const snoozedThreadKeysRef = useRef(snoozedThreadKeys);

const { draftIds, routeDraftComposer } = useSidebarDrafts(scopedProjectKeys, routeDraftIdForRows);
// Numbered jumps follow the rows on screen: drafts first, then threads.
// Search hides the draft rows, so they take no numbers while it is open.
const jumpDraftIds = isSearchingThreads ? EMPTY_DRAFT_IDS : draftIds;
const jumpLabelByKey = (() => {
const mapping = new Map<string, string>();
for (const [index, threadKey] of orderedThreadKeys.entries()) {
const jumpCommand = threadJumpCommandForIndex(index);
const jumpCommand = threadJumpCommandForIndex(jumpDraftIds.length + index);
if (!jumpCommand) break;
const label = shortcutLabelForCommand(keybindings, jumpCommand);
if (label) mapping.set(threadKey, label);
Expand Down Expand Up @@ -2903,12 +2908,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
}
const jumpIndex = threadJumpIndexFromCommand(command ?? "");
if (jumpIndex === null) return;
navigateToThreadKey(orderedThreadKeys[jumpIndex] ?? null);
const jumpDraftId = jumpDraftIds[jumpIndex];
if (jumpDraftId !== undefined) {
event.preventDefault();
event.stopPropagation();
navigateToDraft(jumpDraftId);
return;
}
navigateToThreadKey(orderedThreadKeys[jumpIndex - jumpDraftIds.length] ?? null);
};
window.addEventListener("keydown", onWindowKeyDown);
return () => window.removeEventListener("keydown", onWindowKeyDown);
}, [
jumpDraftIds,
keybindings,
navigateToDraft,
navigateToThread,
orderedThreadKeys,
routeRowThreadKey,
Expand All @@ -2917,6 +2931,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
]);

const showJumpHints = useThreadJumpHintsVisible(keybindings, routeThreadRef);
const draftJumpLabels = showJumpHints
? jumpDraftIds.map((_, index) => {
const jumpCommand = threadJumpCommandForIndex(index);
return jumpCommand ? shortcutLabelForCommand(keybindings, jumpCommand) : null;
})
: null;

const handleNewThreadClick = (event?: ReactMouseEvent) => {
if (
Expand Down Expand Up @@ -3421,9 +3441,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const items: ReactNode[] = [
<SidebarDraftBlock
key="draft-sessions"
draftIds={draftIds}
routeDraftComposer={routeDraftComposer}
jumpLabels={draftJumpLabels}
projectByKey={projectByKey}
projectDisplayNameByKey={projectDisplayNameByKey}
scopedProjectKeys={scopedProjectKeys}
routeDraftId={routeDraftIdForRows}
onNavigateToDraft={navigateToDraft}
onDraftContextMenu={handleDraftContextMenu}
Expand Down
51 changes: 42 additions & 9 deletions apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,23 @@ import { expect, it, vi } from "vite-plus/test";

import { act, render } from "../../../test/render";
import { DraftId, useComposerDraftStore } from "../../composerDraftStore";
import { SidebarDraftBlock } from "./SidebarDraftBlock";
import { SidebarDraftBlock, useSidebarDrafts } from "./SidebarDraftBlock";

function DraftBlock(props: { routeDraftId: string | null }) {
const { draftIds, routeDraftComposer } = useSidebarDrafts(null, props.routeDraftId);
return (
<SidebarDraftBlock
draftIds={draftIds}
routeDraftComposer={routeDraftComposer}
jumpLabels={null}
projectByKey={new Map()}
projectDisplayNameByKey={new Map()}
routeDraftId={props.routeDraftId}
onNavigateToDraft={vi.fn()}
onDraftContextMenu={vi.fn()}
/>
);
}

it("keeps draft previews current through edits, promotion and removal", async () => {
const initial = useComposerDraftStore.getState();
Expand All @@ -25,14 +41,7 @@ it("keeps draft previews current through edits, promotion and removal", async ()
await using screen = await render(
<Profiler id="draft-block" onRender={onRender}>
<ul>
<SidebarDraftBlock
projectByKey={new Map()}
projectDisplayNameByKey={new Map()}
scopedProjectKeys={null}
routeDraftId={null}
onNavigateToDraft={vi.fn()}
onDraftContextMenu={vi.fn()}
/>
<DraftBlock routeDraftId={null} />
</ul>
</Profiler>,
);
Expand Down Expand Up @@ -65,4 +74,28 @@ it("keeps draft previews current through edits, promotion and removal", async ()
await expect.element(row).toHaveAccessibleName("Next draft preview, Unsent draft");
await act(() => initial.clearDraftThread(nextDraftId));
await expect.element(row).not.toBeInTheDocument();

// The open draft keeps the row it had on arrival, so numbered jumps stay put.
const routeDraftId = DraftId.make("route-draft-preview");
await act(() => {
initial.setProjectDraftThreadId(projectRef, routeDraftId, { threadId: ThreadId.make("route") });
initial.setPrompt(routeDraftId, "Route draft preview");
});
await screen.rerender(
<Profiler id="draft-block" onRender={onRender}>
<ul>
<DraftBlock routeDraftId={routeDraftId} />
</ul>
</Profiler>,
);
await act(() => initial.setPrompt(routeDraftId, ""));
await expect.element(row).toHaveAccessibleName("Route draft preview, Unsent draft");
await screen.rerender(
<Profiler id="draft-block" onRender={onRender}>
<ul>
<DraftBlock routeDraftId={null} />
</ul>
</Profiler>,
);
await expect.element(row).not.toBeInTheDocument();
});
Loading
Loading