diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index c1cae81000123..9a3ae7a1608c9 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -1,3 +1,4 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { DEFAULT_CLIENT_SETTINGS, @@ -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"; @@ -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( + + + , + ); + 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"), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 661b50a00b248..dbc8e3f9560d0 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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, @@ -317,6 +317,7 @@ const sidebarProviderEntriesAtom = Atom.make((get) => { }).pipe(Atom.withLabel("sidebar-provider-entries")); const EMPTY_PROVIDER_ENTRIES: ReadonlyMap = new Map(); +const EMPTY_DRAFT_IDS: readonly DraftId[] = []; const EMPTY_THREADS: readonly EnvironmentThreadShell[] = []; const SETTLED_TAIL_INITIAL_COUNT = 10; @@ -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(); 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); @@ -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, @@ -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 ( @@ -3421,9 +3441,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const items: ReactNode[] = [ + ); +} it("keeps draft previews current through edits, promotion and removal", async () => { const initial = useComposerDraftStore.getState(); @@ -25,14 +41,7 @@ it("keeps draft previews current through edits, promotion and removal", async () await using screen = await render(
    - +
, ); @@ -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( + +
    + +
+
, + ); + await act(() => initial.setPrompt(routeDraftId, "")); + await expect.element(row).toHaveAccessibleName("Route draft preview, Unsent draft"); + await screen.rerender( + +
    + +
+
, + ); + await expect.element(row).not.toBeInTheDocument(); }); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index bc15ca5945972..c562fb06c7923 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -12,22 +12,95 @@ import { useShallow } from "zustand/react/shallow"; import { cn } from "~/lib/utils"; import { resolveSidebarRowAccessibility } from "../Sidebar.logic"; import { ProjectFavicon, type ProjectFaviconProject } from "../ProjectFavicon"; +import { Kbd } from "../ui/kbd"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerDraftHasUserContent, DraftId, useComposerDraftStore, type ComposerThreadDraftState, - type DraftSessionState, } from "../../composerDraftStore"; import { draftPenClassName, draftSurfaceClassName } from "./SidebarThreadRow"; +type DraftListState = Pick< + ReturnType, + "draftThreadsByThreadKey" | "draftsByThreadKey" +>; + +function isListedDraft(store: DraftListState, draftKey: string): boolean { + const session = store.draftThreadsByThreadKey[draftKey]; + return ( + session !== undefined && + session.promotedTo == null && + composerDraftHasUserContent(store.draftsByThreadKey[draftKey]) + ); +} + +function listedRouteDraftComposer(routeDraftId: string | null): ComposerThreadDraftState | null { + if (routeDraftId === null) return null; + const store = useComposerDraftStore.getState(); + return isListedDraft(store, routeDraftId) + ? (store.draftsByThreadKey[routeDraftId] ?? null) + : null; +} + +export interface SidebarDrafts { + /** Listed drafts, newest first. The block renders exactly these rows. */ + readonly draftIds: readonly DraftId[]; + /** The route draft as it was on arrival, or null when it is not listed. */ + readonly routeDraftComposer: ComposerThreadDraftState | null; +} + +/** + * Drafts the sidebar lists; numbered jumps count these rows. The route draft + * keeps its arrival row and preview. This lives in the sidebar, not the block, + * because search unmounts the block and must not split a row from its number. + */ +export function useSidebarDrafts( + scopedProjectKeys: ReadonlySet | null, + routeDraftId: string | null, +): SidebarDrafts { + // Ids only, not composers, so typing does not re-render the sidebar. + const candidateIds = useComposerDraftStore( + useShallow((store) => + Object.entries(store.draftThreadsByThreadKey) + .filter( + ([draftKey, session]) => + session.promotedTo == null && + (scopedProjectKeys === null || + scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`)) && + (draftKey === routeDraftId || isListedDraft(store, draftKey)), + ) + .toSorted(([, left], [, right]) => right.createdAt.localeCompare(left.createdAt)) + .map(([draftKey]) => DraftId.make(draftKey)), + ), + ); + const [frozenRoute, setFrozenRoute] = useState<{ + routeDraftId: string | null; + composer: ComposerThreadDraftState | null; + }>({ routeDraftId: null, composer: null }); + if (frozenRoute.routeDraftId !== routeDraftId) { + // Read once on arrival inside the updater; React re-runs this render with it. + setFrozenRoute(() => ({ routeDraftId, composer: listedRouteDraftComposer(routeDraftId) })); + } + const routeDraftComposer = + frozenRoute.routeDraftId === routeDraftId ? frozenRoute.composer : null; + return { + draftIds: + routeDraftComposer !== null + ? candidateIds + : candidateIds.filter((draftId) => draftId !== routeDraftId), + routeDraftComposer, + }; +} + const SidebarDraftRow = memo(function SidebarDraftRow(props: { draftId: DraftId; composer: ComposerThreadDraftState; project: ProjectFaviconProject | null; projectDisplayName: string | null; isActive: boolean; + jumpLabel: string | null; onNavigate: (draftId: DraftId) => void; onDiscard: (draftId: DraftId) => void; onContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; @@ -106,7 +179,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { {props.projectDisplayName} - + :not([data-jump-hint])]:hidden", + )} + > Discard draft + {props.jumpLabel ? ( + + {props.jumpLabel} + + ) : null}
@@ -133,16 +216,13 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { ); }); -interface SidebarDraftRowData { - draftId: DraftId; - session: DraftSessionState; - composer: ComposerThreadDraftState; -} - export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + draftIds: readonly DraftId[]; + routeDraftComposer: ComposerThreadDraftState | null; + /** Labels by list position, or null while hints are hidden. */ + jumpLabels: readonly (string | null)[] | null; projectByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; - scopedProjectKeys: ReadonlySet | null; routeDraftId: string | null; onNavigateToDraft: (draftId: DraftId) => void; onDraftContextMenu: (draftId: DraftId, position: { x: number; y: number }) => void; @@ -157,56 +237,19 @@ export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { ), ), ); - const [frozenActive, setFrozenActive] = useState<{ - routeDraftId: string | null; - row: SidebarDraftRowData | null; - }>({ routeDraftId: null, row: null }); - if (frozenActive.routeDraftId !== props.routeDraftId) { - let row: SidebarDraftRowData | null = null; - if (props.routeDraftId !== null) { - const draftId = DraftId.make(props.routeDraftId); - const session = draftThreadsByThreadKey[draftId]; - const composer = draftsByThreadKey[draftId]; - row = - session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) - ? { draftId, session, composer } - : null; - } - setFrozenActive({ routeDraftId: props.routeDraftId, row }); - } - const drafts = (() => { - const rows: SidebarDraftRowData[] = []; - for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { - if (session.promotedTo != null) { - continue; - } - if ( - props.scopedProjectKeys !== null && - !props.scopedProjectKeys.has(`${session.environmentId}:${session.projectId}`) - ) { - continue; - } - if (draftKey === props.routeDraftId) { - if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { - rows.push(frozenActive.row); - } - continue; - } - const composer = draftsByThreadKey[draftKey]; - if (!composer || !composerDraftHasUserContent(composer)) { - continue; - } - rows.push({ draftId: DraftId.make(draftKey), session, composer }); - } - rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); - return rows; - })(); + const drafts = props.draftIds.flatMap((draftId, slot) => { + const session = draftThreadsByThreadKey[draftId]; + const composer = + (draftId === props.routeDraftId ? props.routeDraftComposer : null) ?? + draftsByThreadKey[draftId]; + return session && composer ? [{ draftId, slot, session, composer }] : []; + }); if (drafts.length === 0) { return null; } return ( <> - {drafts.map(({ composer, draftId, session }) => { + {drafts.map(({ composer, draftId, session, slot }) => { const projectKey = `${session.environmentId}:${session.projectId}`; return (