Skip to content

Commit 23d7a3d

Browse files
t3dotggclaude
andcommitted
Sidebar v2: per-card project title bars, static creation order
- Each card is a tiny window: a thin title bar carries the project favicon + mono uppercase name with the timestamp, separated from the thread content by a hairline — project identity reads as chrome - Active card brightens its title bar on top of the near-white border - Thread list is statically ordered by creation time, newest first; activity never reorders rows — only settling moves them - Project identity mirrored on the open thread: favicon+name chip leads the chat header and the composer's run-context strip (drafts included) - Settled rows keep dimmed favicons; chips/picker favicons restored - Composer squared to match: 12px radius, higher-contrast border, neutral focus ring Co-Authored-By: Claude Fable 5 <[email protected]>
1 parent ab23a82 commit 23d7a3d

7 files changed

Lines changed: 184 additions & 141 deletions

File tree

‎apps/web/src/components/BranchToolbar.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";
2525
import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
2626
import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
27+
import { ProjectFavicon } from "./ProjectFavicon";
2728
import { Button } from "./ui/button";
2829
import {
2930
Menu,
@@ -240,6 +241,21 @@ export const BranchToolbar = memo(function BranchToolbar({
240241

241242
return (
242243
<div className="mx-auto flex w-full max-w-3xl items-center gap-2 px-2.5 pb-3 pt-1 sm:px-3">
244+
{/* The submit target leads the run-context strip: this is the one
245+
surface where "which project am I about to send this to" must be
246+
answered before the first keystroke, especially on drafts.
247+
Mirrors the sidebar cards' title bar — favicon + mono name. */}
248+
<span className="inline-flex shrink-0 items-center gap-1.5 border border-black/12 py-0.5 pl-1.5 pr-2 dark:border-white/12">
249+
<ProjectFavicon
250+
environmentId={activeProject.environmentId}
251+
cwd={activeProject.workspaceRoot}
252+
className="size-3"
253+
/>
254+
<span className="max-w-32 truncate font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
255+
{activeProject.title}
256+
</span>
257+
</span>
258+
<Separator orientation="vertical" className="mx-0.5 h-3.5!" />
243259
{isMobile ? (
244260
<MobileRunContextSelector
245261
envLocked={envLocked}

‎apps/web/src/components/ChatView.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5040,6 +5040,7 @@ function ChatViewContent(props: ChatViewProps) {
50405040
{...(routeKind === "draft" && draftId ? { draftId } : {})}
50415041
activeThreadTitle={activeThread.title}
50425042
activeProjectName={activeProject?.title}
5043+
activeProjectCwd={activeProject?.workspaceRoot ?? null}
50435044
openInCwd={gitCwd}
50445045
activeProjectScripts={activeProject?.scripts}
50455046
preferredScriptId={

‎apps/web/src/components/Sidebar.logic.test.ts‎

Lines changed: 15 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -619,44 +619,28 @@ describe("resolveSidebarV2Status", () => {
619619
});
620620

621621
describe("sortThreadsForSidebarV2", () => {
622-
const sortable = (input: {
623-
id: string;
624-
hasPendingApprovals?: boolean;
625-
latestUserMessageAt: string;
626-
updatedAt?: string;
627-
}) => ({
622+
const sortable = (input: { id: string; createdAt: string }) => ({
628623
id: input.id,
629-
hasPendingApprovals: input.hasPendingApprovals ?? false,
630-
session: null,
631-
createdAt: "2026-03-09T10:00:00.000Z",
632-
updatedAt: input.updatedAt ?? input.latestUserMessageAt,
633-
latestUserMessageAt: input.latestUserMessageAt,
624+
createdAt: input.createdAt,
634625
});
635626

636-
it("pins approval-blocked threads above the recency flow, longest wait first", () => {
627+
it("orders by creation time, newest first, ignoring activity", () => {
637628
const sorted = sortThreadsForSidebarV2([
638-
sortable({ id: "fresh", latestUserMessageAt: "2026-03-09T12:00:00.000Z" }),
639-
sortable({
640-
id: "approval-new",
641-
hasPendingApprovals: true,
642-
latestUserMessageAt: "2026-03-09T08:00:00.000Z",
643-
updatedAt: "2026-03-09T11:00:00.000Z",
644-
}),
645-
sortable({
646-
id: "approval-old",
647-
hasPendingApprovals: true,
648-
latestUserMessageAt: "2026-03-09T11:30:00.000Z",
649-
updatedAt: "2026-03-09T09:00:00.000Z",
650-
}),
651-
sortable({ id: "stale", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }),
629+
sortable({ id: "oldest", createdAt: "2026-03-09T08:00:00.000Z" }),
630+
sortable({ id: "newest", createdAt: "2026-03-09T12:00:00.000Z" }),
631+
sortable({ id: "middle", createdAt: "2026-03-09T10:00:00.000Z" }),
652632
]);
653633

654-
expect(sorted.map((thread) => thread.id)).toEqual([
655-
"approval-old",
656-
"approval-new",
657-
"fresh",
658-
"stale",
634+
expect(sorted.map((thread) => thread.id)).toEqual(["newest", "middle", "oldest"]);
635+
});
636+
637+
it("breaks creation-time ties by id so the order is stable", () => {
638+
const sorted = sortThreadsForSidebarV2([
639+
sortable({ id: "b", createdAt: "2026-03-09T10:00:00.000Z" }),
640+
sortable({ id: "a", createdAt: "2026-03-09T10:00:00.000Z" }),
659641
]);
642+
643+
expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
660644
});
661645
});
662646

‎apps/web/src/components/Sidebar.logic.ts‎

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -379,29 +379,17 @@ export function resolveSidebarV2Status(thread: SidebarV2StatusInput): SidebarV2S
379379
return "ready";
380380
}
381381

382-
// v2 sort: approval-blocked threads pin above the recency flow, longest wait
383-
// first, so nothing actionable can scroll away. Everything else is recency.
382+
// v2 sort: static creation order, newest thread on top. Activity NEVER
383+
// reorders the list — a row holds its position from open until settled, so
384+
// the screen only moves at lifecycle transitions. Status (including pending
385+
// approval) is carried by each card's edge strip, not by position.
384386
export function sortThreadsForSidebarV2<
385-
T extends SidebarV2StatusInput & ThreadSortInput & { readonly id: string },
387+
T extends { readonly id: string; readonly createdAt: string },
386388
>(threads: readonly T[]): T[] {
387-
return [...threads].toSorted((left, right) => {
388-
const leftApproval = resolveSidebarV2Status(left) === "approval";
389-
const rightApproval = resolveSidebarV2Status(right) === "approval";
390-
if (leftApproval !== rightApproval) {
391-
return leftApproval ? -1 : 1;
392-
}
393-
// Approval activities update the shell's updatedAt in the projection
394-
// pipeline, making it the best available request-time proxy. Oldest waits
395-
// pin first. (The shell does not expose the approval request timestamp.)
396-
if (leftApproval && rightApproval) {
397-
return (
398-
Date.parse(left.updatedAt) - Date.parse(right.updatedAt) || left.id.localeCompare(right.id)
399-
);
400-
}
401-
const byRecency =
402-
getThreadSortTimestamp(right, "updated_at") - getThreadSortTimestamp(left, "updated_at");
403-
return byRecency || left.id.localeCompare(right.id);
404-
});
389+
return [...threads].toSorted(
390+
(left, right) =>
391+
Date.parse(right.createdAt) - Date.parse(left.createdAt) || left.id.localeCompare(right.id),
392+
);
405393
}
406394

407395
export function resolveThreadStatusPill(input: {

0 commit comments

Comments
 (0)