From 5bca232aa074420201fdf761d855f601b6bd972d Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:33:03 -0400 Subject: [PATCH 1/2] Surface unsent drafts in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a new-thread composer and navigating away used to strand the draft: it survived in the store but nothing pointed back to it, and the next "new thread" could repurpose or garbage-collect it. Now every draft with real user content (typed text, attachments, or context chips) gets a compact row at the top of the sidebar — project name over the first line of the prompt — that navigates back to /draft/$draftId with everything intact. New-thread surfaces only ever reuse untouched drafts; an invested draft stays parked in the sidebar and a fresh one is minted beside it. Discarding a row asks for confirmation, matching the prompt stash. Store changes: composerDraftHasUserContent gates remap GC and persistence (invested sessions survive unmapped, zombie sessions drop with their composer blobs), clearProjectDraftThreadId sweeps a project's unmapped sessions too, and getDraftSessionByProjectRef prefers the mapped draft. --- apps/web/src/components/Sidebar.tsx | 75 +++- apps/web/src/components/sidebar/InboxRows.tsx | 6 +- .../sidebar/SidebarDrafts.browser.tsx | 177 ++++++++ .../src/components/sidebar/SidebarDrafts.tsx | 396 ++++++++++++++++++ apps/web/src/composerDraftStore.test.ts | 138 +++++- apps/web/src/composerDraftStore.ts | 125 +++++- apps/web/src/hooks/useHandleNewThread.ts | 37 +- 7 files changed, 929 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarDrafts.browser.tsx create mode 100644 apps/web/src/components/sidebar/SidebarDrafts.tsx diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index faec5baf7..cd83a512d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -55,7 +55,8 @@ import { import { useModelPickerOpen } from "../modelPickerOpenState"; import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; +import { preserveRightPanelSearchParamsForDraftNavigation } from "../diffRouteSearch"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { useRelativeTimeTick } from "../hooks/useRelativeTimeTick"; import { retainThreadDetailSubscription } from "../environments/runtime/service"; @@ -105,6 +106,11 @@ import { type ThreadStatusPill, } from "./Sidebar.logic"; import { InboxDoneRow, InboxThreadRow } from "./sidebar/InboxRows"; +import { + countVisibleDraftSessions, + SidebarDraftBlock, + type SidebarDraftProjectInfo, +} from "./sidebar/SidebarDrafts"; import { ProjectScopeMenu } from "./sidebar/ProjectScopeMenu"; import { SidebarHoverCardGroup } from "./sidebar/hoverCard"; import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard"; @@ -581,6 +587,43 @@ export default function Sidebar() { ? inboxProjectScopeKey : null; + // Everything a draft row needs about its project, resolved once here: a + // draft carries a scoped project ref, and the rows want the grouped display + // name, the checkout the favicon comes from, and the logical key the inbox + // scope filters on. + const draftProjectInfoByScopedRef = useMemo(() => { + const infoByScopedRef = new Map(); + for (const project of projects) { + const projectRef = scopeProjectRef(project.environmentId, project.id); + const projectKey = resolveProjectKeyForRef(projectRef); + infoByScopedRef.set(scopedProjectKey(projectRef), { + projectKey, + displayName: sidebarProjectByKey.get(projectKey)?.displayName ?? project.name, + cwd: project.cwd, + isGeneralChat: project.kind === "general-chat", + }); + } + return infoByScopedRef; + }, [projects, resolveProjectKeyForRef, sidebarProjectByKey]); + const routeDraftId = useParams({ + strict: false, + select: (params) => { + const target = resolveThreadRouteTarget(params); + return target?.kind === "draft" ? target.draftId : null; + }, + }); + // Count-only subscription: the sidebar needs "are there draft rows" for its + // empty state, while SidebarDraftBlock owns the per-keystroke content + // subscription. Selecting a number keeps typing in a draft composer from + // re-rendering the whole sidebar. + const visibleDraftSessionCount = useComposerDraftStore((store) => + countVisibleDraftSessions({ + store, + projectInfoByScopedRef: draftProjectInfoByScopedRef, + scopedProjectKey: scopedProjectKeyValue, + }), + ); + const scopeOptions = useMemo(() => { const lastActivityMsByKey = new Map(); const needsYouCountByKey = new Map(); @@ -696,6 +739,25 @@ export default function Sidebar() { [clearSelection, isMobile, navigate, setOpenMobile, setSelectionAnchor], ); + const navigateToDraft = useCallback( + (draftId: DraftId) => { + // Unconditional: also drops a stale selection anchor left by plain-click + // navigation, so a later shift-click starts fresh instead of ranging from + // a row that is no longer the context. (clearSelection no-ops when there + // is nothing to clear.) + clearSelection(); + if (isMobile) { + setOpenMobile(false); + } + void navigate({ + to: "/draft/$draftId", + params: { draftId }, + search: preserveRightPanelSearchParamsForDraftNavigation, + }); + }, + [clearSelection, isMobile, navigate, setOpenMobile], + ); + const handleThreadClick = useCallback( (event: React.MouseEvent, threadRef: ScopedThreadRef, rowKeys: readonly string[]) => { const isMac = isMacPlatform(navigator.platform); @@ -1391,7 +1453,16 @@ export default function Sidebar() { newThreadShortcutLabel={newThreadShortcutLabel} /> - {liveEntries.length === 0 ? ( + {/* Unsent drafts sit above the inbox: an interrupted "new + thread" is the one row you want back in one click. */} + + + {liveEntries.length === 0 && visibleDraftSessionCount === 0 ? (
{!bootstrapComplete diff --git a/apps/web/src/components/sidebar/InboxRows.tsx b/apps/web/src/components/sidebar/InboxRows.tsx index 7feee5a32..5831aeea7 100644 --- a/apps/web/src/components/sidebar/InboxRows.tsx +++ b/apps/web/src/components/sidebar/InboxRows.tsx @@ -38,11 +38,11 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; const ROW_ITEM_CLASS_NAME = "group/thread-row relative w-full"; -const ROW_SURFACE_CLASS_NAME = +export const ROW_SURFACE_CLASS_NAME = "relative w-full cursor-pointer select-none text-left outline-hidden focus-ring focus-visible:ring-inset"; /** Hover and selection are colour shifts only — nothing moves under the cursor. */ -function resolveRowSurfaceTone(input: { isActive: boolean; isSelected: boolean }): string { +export function resolveRowSurfaceTone(input: { isActive: boolean; isSelected: boolean }): string { if (input.isSelected) { return "bg-primary/15 dark:bg-primary/22 hover:bg-primary/19 dark:hover:bg-primary/28"; } @@ -83,7 +83,7 @@ const ROW_META_SLOT_CLASS_NAME = "relative ml-auto flex flex-none items-center gap-1.5 whitespace-nowrap"; /** `relative` lifts the buttons above their own backdrop layers. */ -const ROW_ACTION_BUTTON_CLASS_NAME = +export const ROW_ACTION_BUTTON_CLASS_NAME = "relative inline-flex size-5 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors pointer-coarse:size-7 hover:text-foreground focus-ring"; /** diff --git a/apps/web/src/components/sidebar/SidebarDrafts.browser.tsx b/apps/web/src/components/sidebar/SidebarDrafts.browser.tsx new file mode 100644 index 000000000..3f182e2d1 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDrafts.browser.tsx @@ -0,0 +1,177 @@ +// The rows truncate and reveal their discard button from CSS, so the +// production stylesheet is part of the behaviour under test. +import "../../index.css"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { EnvironmentId, ProjectId, ThreadId } from "@threadlines/contracts"; +import { scopedProjectKey, scopeProjectRef } from "@threadlines/client-runtime"; +import { useState } from "react"; +import { page } from "vite-plus/test/browser"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { SidebarDraftBlock, type SidebarDraftProjectInfo } from "./SidebarDrafts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const PROJECT_ID = ProjectId.make("project-badcode"); +const OTHER_PROJECT_ID = ProjectId.make("project-marketing"); +const PROJECT_REF = scopeProjectRef(ENVIRONMENT_ID, PROJECT_ID); +const OTHER_PROJECT_REF = scopeProjectRef(ENVIRONMENT_ID, OTHER_PROJECT_ID); + +const PROJECT_INFO = new Map([ + [ + scopedProjectKey(PROJECT_REF), + { + projectKey: scopedProjectKey(PROJECT_REF), + displayName: "badcode", + cwd: "/Users/test/badcode", + isGeneralChat: false, + }, + ], + [ + scopedProjectKey(OTHER_PROJECT_REF), + { + projectKey: scopedProjectKey(OTHER_PROJECT_REF), + displayName: "marketing", + cwd: "/Users/test/marketing", + isGeneralChat: false, + }, + ], +]); + +function seedDraft(input: { + draftId: DraftId; + projectRef: typeof PROJECT_REF; + createdAt: string; + prompt?: string; +}): void { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(input.projectRef, input.draftId, { + threadId: ThreadId.make(`thread-${input.draftId}`), + createdAt: input.createdAt, + }); + if (input.prompt !== undefined) { + store.setPrompt(input.draftId, input.prompt); + } +} + +function renderDraftBlock(input: { + routeDraftId?: string | null; + onNavigateToDraft?: (draftId: DraftId) => void; +}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + // The route the user is on is the one prop that changes under this block, so + // the harness owns it — that is what freezes the open draft's row. + function Harness() { + const [routeDraftId, setRouteDraftId] = useState(input.routeDraftId ?? null); + return ( + + + + + ); + } + return render(); +} + +describe("SidebarDraftBlock", () => { + beforeEach(() => { + useComposerDraftStore.setState({ + draftsByThreadKey: {}, + draftThreadsByThreadKey: {}, + logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + stickyModelSelectionByProvider: {}, + stickyActiveProvider: null, + }); + }); + + it("lists invested drafts newest first and navigates back to one on click", async () => { + seedDraft({ + draftId: DraftId.make("draft-old"), + projectRef: PROJECT_REF, + createdAt: "2026-08-01T00:00:00.000Z", + prompt: "older idea\nsecond line", + }); + seedDraft({ + draftId: DraftId.make("draft-new"), + projectRef: OTHER_PROJECT_REF, + createdAt: "2026-08-02T00:00:00.000Z", + prompt: "newer idea", + }); + // Settings alone are not user content, so this one earns no row. + seedDraft({ + draftId: DraftId.make("draft-empty"), + projectRef: PROJECT_REF, + createdAt: "2026-08-03T00:00:00.000Z", + }); + const onNavigateToDraft = vi.fn(); + + renderDraftBlock({ onNavigateToDraft }); + + const previews = page.getByTestId("sidebar-draft-preview"); + await expect.element(previews.first()).toHaveTextContent("newer idea"); + expect(await previews.all()).toHaveLength(2); + // Only the first line of a multi-line prompt. + await expect.element(previews.nth(1)).toHaveTextContent("older idea"); + await expect.element(previews.nth(1)).not.toHaveTextContent("second line"); + + await page.getByTestId("sidebar-draft-row").first().click(); + + expect(onNavigateToDraft).toHaveBeenCalledWith(DraftId.make("draft-new")); + }); + + it("confirms before discarding a draft", async () => { + seedDraft({ + draftId: DraftId.make("draft-old"), + projectRef: PROJECT_REF, + createdAt: "2026-08-01T00:00:00.000Z", + prompt: "typed work worth keeping", + }); + + renderDraftBlock({}); + + await page.getByTestId("sidebar-draft-discard").click(); + await expect.element(page.getByText("Discard draft?")).toBeVisible(); + // The draft is still there while the dialog is open. + expect(useComposerDraftStore.getState().getDraftSession(DraftId.make("draft-old"))).not.toBe( + null, + ); + + await page.getByRole("button", { name: "Discard" }).click(); + + await expect.element(page.getByTestId("sidebar-draft-row")).not.toBeInTheDocument(); + expect(useComposerDraftStore.getState().getDraftSession(DraftId.make("draft-old"))).toBe(null); + }); + + it("shows no row for the open draft until the user navigates away from it", async () => { + const draftId = DraftId.make("draft-open"); + seedDraft({ + draftId, + projectRef: PROJECT_REF, + createdAt: "2026-08-01T00:00:00.000Z", + }); + + renderDraftBlock({ routeDraftId: draftId }); + + // Typing in the draft you are looking at must not push a row into the + // sidebar under your cursor. + useComposerDraftStore.getState().setPrompt(draftId, "still writing this"); + await expect.element(page.getByTestId("sidebar-draft-row")).not.toBeInTheDocument(); + + await page.getByTestId("leave-draft").click(); + + await expect + .element(page.getByTestId("sidebar-draft-preview")) + .toHaveTextContent("still writing this"); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarDrafts.tsx b/apps/web/src/components/sidebar/SidebarDrafts.tsx new file mode 100644 index 000000000..400b42e1a --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarDrafts.tsx @@ -0,0 +1,396 @@ +import { SquarePenIcon, XIcon } from "lucide-react"; +import { + memo, + useCallback, + useMemo, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { scopedProjectKey, scopeProjectRef } from "@threadlines/client-runtime"; +import { + composerDraftHasUserContent, + DraftId, + useComposerDraftStore, + type ComposerThreadDraftState, + type DraftSessionState, +} from "../../composerDraftStore"; +import { cn } from "../../lib/utils"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { + resolveRowSurfaceTone, + ROW_ACTION_BUTTON_CLASS_NAME, + ROW_SURFACE_CLASS_NAME, +} from "./InboxRows"; + +const SNIPPET_MAX_CHARS = 90; + +/** + * What the sidebar knows about the project a draft session targets, keyed by + * scoped project ref. Built once by the sidebar so a draft row costs no store + * subscription of its own. + */ +export interface SidebarDraftProjectInfo { + /** Logical (possibly grouped) key, the same one the inbox scope filters on. */ + projectKey: string; + displayName: string; + cwd: string; + isGeneralChat: boolean; +} + +export type SidebarDraftProjectInfoByScopedRef = ReadonlyMap; + +interface DraftSessionSlices { + draftThreadsByThreadKey: Record; + draftsByThreadKey: Record; +} + +function draftProjectRefKey(session: DraftSessionState): string { + return scopedProjectKey(scopeProjectRef(session.environmentId, session.projectId)); +} + +/** + * The half of the row filter that does not depend on composer content, so the + * open draft's frozen row and the live rows can share it. + * + * General chats have their own page and never join the inbox, so their drafts + * stay out of it too. A draft whose project is unknown here (another machine, + * or a project removed since) still gets a row while the inbox is unscoped — + * it cannot belong to a scope it has no key for. + */ +function draftSessionMatchesScope(input: { + project: SidebarDraftProjectInfo | undefined; + scopedProjectKey: string | null; +}): boolean { + if (input.project?.isGeneralChat === true) { + return false; + } + if (input.scopedProjectKey === null) { + return true; + } + return input.project?.projectKey === input.scopedProjectKey; +} + +/** + * How many draft rows the block will render, for the sidebar's empty state. + * + * Selecting a number keeps typing in a draft composer from re-rendering the + * whole sidebar — {@link SidebarDraftBlock} owns the per-keystroke content + * subscription. It can overcount by one for an open draft that was never + * navigated away from (that one renders no row), which only softens the empty + * state. + */ +export function countVisibleDraftSessions(input: { + store: DraftSessionSlices; + projectInfoByScopedRef: SidebarDraftProjectInfoByScopedRef; + scopedProjectKey: string | null; +}): number { + let count = 0; + for (const [draftKey, session] of Object.entries(input.store.draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if (!composerDraftHasUserContent(input.store.draftsByThreadKey[draftKey])) { + continue; + } + if ( + !draftSessionMatchesScope({ + project: input.projectInfoByScopedRef.get(draftProjectRefKey(session)), + scopedProjectKey: input.scopedProjectKey, + }) + ) { + continue; + } + count += 1; + } + return count; +} + +/** + * The row's one line of content: the first line of the typed prompt, or a + * count of the chips a wordless draft carries. + */ +function describeComposerDraft(draft: ComposerThreadDraftState): string { + const promptPreview = draft.prompt.trim().split("\n", 1)[0] ?? ""; + if (promptPreview.length > 0) { + return promptPreview; + } + // `attachments` mirrors `persistedAttachments` once rehydration finishes; + // before that only the persisted list is populated, hence max not sum. + const attachmentCount = Math.max(draft.attachments.length, draft.persistedAttachments.length); + // Same vocabulary as the prompt stash: files are attachments, everything + // else the composer carries is a chip. + const chipCount = + draft.terminalContexts.length + + draft.transcriptHighlightContexts.length + + draft.fileSelectionContexts.length + + draft.pickedElementContexts.length + + draft.drawingContexts.length; + if (attachmentCount > 0) { + return `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; + } + return `${chipCount} chip${chipCount === 1 ? "" : "s"}`; +} + +function toSnippet(text: string): string { + return text.length > SNIPPET_MAX_CHARS ? `${text.slice(0, SNIPPET_MAX_CHARS - 1)}…` : text; +} + +interface SidebarDraftRowData { + draftId: DraftId; + session: DraftSessionState; + composer: ComposerThreadDraftState; +} + +/** + * One unsent draft session the user has invested content in. Two lines, + * nothing else: project name, then the typed prompt. All the draft's settings + * (model, env mode, branch, worktree) still travel with it — clicking is a + * plain navigation to /draft/$draftId, which touches nothing. + * + * While the draft is open the row renders a frozen snapshot (see + * {@link SidebarDraftBlock}); memoized so per-keystroke block re-renders skip + * it entirely. + */ +const SidebarDraftRow = memo(function SidebarDraftRow(props: { + draftId: DraftId; + composer: ComposerThreadDraftState; + session: DraftSessionState; + project: SidebarDraftProjectInfo | undefined; + isActive: boolean; + onNavigate: (draftId: DraftId) => void; + onRequestDiscard: (row: SidebarDraftRowData) => void; +}) { + const { composer, draftId, isActive, onNavigate, onRequestDiscard, project, session } = props; + const preview = describeComposerDraft(composer); + const handleActivate = useCallback(() => { + onNavigate(draftId); + }, [draftId, onNavigate]); + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + // Keys targeting the nested discard button belong to the button: + // preventDefault here would swallow Space's synthesized click and + // navigate instead of discarding. + if ((event.target as HTMLElement).closest("button")) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onNavigate(draftId); + }, + [draftId, onNavigate], + ); + const handleDiscardClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onRequestDiscard({ composer, draftId, session }); + }, + [composer, draftId, onRequestDiscard, session], + ); + return ( +
  • +
    +
    +
    + + {preview} + +
    +
  • + ); +}); + +/** + * Draft sessions with user content, surfaced above the inbox rows so an + * interrupted "new thread" stays one click away. Self-contained (own store + * subscription + closing divider) so per-keystroke composer updates re-render + * only this block, never the whole sidebar. Vanishes at count 0. + */ +export const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + projectInfoByScopedRef: SidebarDraftProjectInfoByScopedRef; + scopedProjectKey: string | null; + routeDraftId: string | null; + onNavigateToDraft: (draftId: DraftId) => void; +}) { + const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); + const draftsByThreadKey = useComposerDraftStore((store) => store.draftsByThreadKey); + const clearDraftThread = useComposerDraftStore((store) => store.clearDraftThread); + const [pendingDiscard, setPendingDiscard] = useState(null); + // The open draft's row is FROZEN at the moment the draft became the route: + // it stays visible (like a thread row) but never repaints while the user + // types. A draft that was never navigated away from has no snapshot to + // freeze, so a fresh typing session shows no row at all. Captured + // synchronously on route change (setState-during-render derived state) so the + // row never flickers out for a frame between route change and capture. + 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 store = useComposerDraftStore.getState(); + const session = store.getDraftSession(draftId); + const composer = store.getComposerDraft(draftId); + row = + session && session.promotedTo == null && composer && composerDraftHasUserContent(composer) + ? { composer, draftId, session } + : null; + } + setFrozenActive({ routeDraftId: props.routeDraftId, row }); + } + const { projectInfoByScopedRef, routeDraftId, scopedProjectKey: scopeKey } = props; + const drafts = useMemo(() => { + const rows: SidebarDraftRowData[] = []; + // Every non-promoted session with content gets a row, mapped or not: + // new-thread surfaces mint fresh drafts and leave invested ones behind + // unmapped, so the mapping only knows about the latest per project. + for (const [draftKey, session] of Object.entries(draftThreadsByThreadKey)) { + if (session.promotedTo != null) { + continue; + } + if ( + !draftSessionMatchesScope({ + project: projectInfoByScopedRef.get(draftProjectRefKey(session)), + scopedProjectKey: scopeKey, + }) + ) { + continue; + } + if (draftKey === routeDraftId) { + // Open draft: render the frozen entry snapshot, or nothing for a draft + // that has never been left. Gated on the LIVE session above so send and + // discard still remove the row immediately. + if (frozenActive.routeDraftId === draftKey && frozenActive.row !== null) { + rows.push(frozenActive.row); + } + continue; + } + const composer = draftsByThreadKey[draftKey]; + if (!composer || !composerDraftHasUserContent(composer)) { + continue; + } + rows.push({ composer, draftId: DraftId.make(draftKey), session }); + } + rows.sort((left, right) => right.session.createdAt.localeCompare(left.session.createdAt)); + return rows; + }, [ + draftThreadsByThreadKey, + draftsByThreadKey, + frozenActive, + projectInfoByScopedRef, + routeDraftId, + scopeKey, + ]); + const handleDiscardOpenChange = useCallback((open: boolean) => { + if (!open) { + setPendingDiscard(null); + } + }, []); + const confirmDiscard = useCallback(() => { + if (pendingDiscard === null) { + return; + } + // The /draft/$draftId route redirects home on its own when the draft it + // renders disappears, so discarding the open draft needs no special-casing + // here. + clearDraftThread(pendingDiscard.draftId); + setPendingDiscard(null); + }, [clearDraftThread, pendingDiscard]); + if (drafts.length === 0) { + return null; + } + return ( + <> +
      + {drafts.map((row) => ( + + ))} +
    +