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
83 changes: 81 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -105,6 +106,12 @@ import {
type ThreadStatusPill,
} from "./Sidebar.logic";
import { InboxDoneRow, InboxThreadRow } from "./sidebar/InboxRows";
import {
countVisibleDraftSessions,
SidebarDraftBlock,
useFrozenOpenDraftRow,
type SidebarDraftProjectInfo,
} from "./sidebar/SidebarDrafts";
import { ProjectScopeMenu } from "./sidebar/ProjectScopeMenu";
import { SidebarHoverCardGroup } from "./sidebar/hoverCard";
import { ThreadHoverCardProvider } from "./sidebar/ThreadHoverCard";
Expand Down Expand Up @@ -581,6 +588,49 @@ 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<string, SidebarDraftProjectInfo>();
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;
},
});
// Only recaptured on route change, so it costs nothing per keystroke.
const frozenOpenDraftRow = useFrozenOpenDraftRow(routeDraftId);
// 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; the count matches the block's rendered
// rows exactly, so "No threads yet" stays up while a never-left draft is
// being typed (it has no row to replace the text with).
const visibleDraftSessionCount = useComposerDraftStore((store) =>
countVisibleDraftSessions({
store,
projectInfoByScopedRef: draftProjectInfoByScopedRef,
scopedProjectKey: scopedProjectKeyValue,
routeDraftId,
frozenOpenDraftRow,
}),
);

const scopeOptions = useMemo(() => {
const lastActivityMsByKey = new Map<string, number>();
const needsYouCountByKey = new Map<string, number>();
Expand Down Expand Up @@ -696,6 +746,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);
Expand Down Expand Up @@ -1391,7 +1460,17 @@ 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. */}
<SidebarDraftBlock
projectInfoByScopedRef={draftProjectInfoByScopedRef}
scopedProjectKey={scopedProjectKeyValue}
routeDraftId={routeDraftId}
frozenOpenDraftRow={frozenOpenDraftRow}
onNavigateToDraft={navigateToDraft}
/>

{liveEntries.length === 0 && visibleDraftSessionCount === 0 ? (
<div className="flex flex-col items-start gap-1.5 px-3 py-2">
<span className="text-[11px] text-muted-foreground/60">
{!bootstrapComplete
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/sidebar/InboxRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -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";

/**
Expand Down
203 changes: 203 additions & 0 deletions apps/web/src/components/sidebar/SidebarDrafts.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
// 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 {
countVisibleDraftSessions,
SidebarDraftBlock,
useFrozenOpenDraftRow,
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<string, SidebarDraftProjectInfo>([
[
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 harness owns the route and the frozen open-draft row, the same way the
// sidebar does, and renders the count the sidebar's empty state gates on —
// the contract under test is that count and rendered rows never disagree.
function Harness() {
const [routeDraftId, setRouteDraftId] = useState<string | null>(input.routeDraftId ?? null);
const frozenOpenDraftRow = useFrozenOpenDraftRow(routeDraftId);
const visibleDraftSessionCount = useComposerDraftStore((store) =>
countVisibleDraftSessions({
store,
projectInfoByScopedRef: PROJECT_INFO,
scopedProjectKey: null,
routeDraftId,
frozenOpenDraftRow,
}),
);
return (
<QueryClientProvider client={queryClient}>
<button type="button" data-testid="leave-draft" onClick={() => setRouteDraftId(null)}>
Leave draft
</button>
<span data-testid="visible-draft-count">{visibleDraftSessionCount}</span>
<SidebarDraftBlock
projectInfoByScopedRef={PROJECT_INFO}
scopedProjectKey={null}
routeDraftId={routeDraftId}
frozenOpenDraftRow={frozenOpenDraftRow}
onNavigateToDraft={input.onNavigateToDraft ?? vi.fn()}
/>
</QueryClientProvider>
);
}
return render(<Harness />);
}

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 });
// Let the mounted harness freeze the still-empty open draft before typing,
// the way the real sidebar is mounted long before a keystroke can land —
// React commits the first render asynchronously, so typing first would let
// the freeze capture the typed content.
await expect.element(page.getByTestId("visible-draft-count")).toHaveTextContent("0");

// Typing in the draft you are looking at must not push a row into the
// sidebar under your cursor — and the count the empty state gates on must
// agree, or "No threads yet" vanishes with no row replacing it.
useComposerDraftStore.getState().setPrompt(draftId, "still writing this");
await expect.element(page.getByTestId("sidebar-draft-row")).not.toBeInTheDocument();
await expect.element(page.getByTestId("visible-draft-count")).toHaveTextContent("0");

await page.getByTestId("leave-draft").click();

await expect
.element(page.getByTestId("sidebar-draft-preview"))
.toHaveTextContent("still writing this");
await expect.element(page.getByTestId("visible-draft-count")).toHaveTextContent("1");
});
});
Loading
Loading