From d39c6b8ddddbb23853ba73e288e491536c682d2b Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:14:57 -0400 Subject: [PATCH 01/18] Sketch the inbox sidebar Studied from the competitor's Sidebar v2, which is a better piece of thinking than its screenshot suggests. The parts worth taking are not the cards: they are the sort discipline (creation order, held from open until settled, so the screen only moves when a lifecycle does), the three-color status budget, and unread as its own axis rather than a status. Translated into rows and dividers at two lines apiece, with projects demoted from tree to filter -- which is the change that retires the rail: once every row carries its own project, nothing needs tracing back to a group header. Settle becomes Done. Snooze deferred whole. A sketch, not a build; open questions for Will are in the file. --- docs/design/sidebar-inbox.html | 334 +++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/design/sidebar-inbox.html diff --git a/docs/design/sidebar-inbox.html b/docs/design/sidebar-inbox.html new file mode 100644 index 00000000..26121cfe --- /dev/null +++ b/docs/design/sidebar-inbox.html @@ -0,0 +1,334 @@ + + + + + + + + Inbox Sidebar + + + +

Inbox sidebar

+

+ Threads are the inbox. One live list ordered by when work began, a Done tail ordered by + when work ended, and the rail retired. Rows and hairline dividers, not cards: two lines, + ~46px, where T3 spends 78px on three. +

+ +
+
+
Proposed — resting state
+ +
+ +
+
Same rows — hover affordances forced
+ + +
+

What we take from T3

+ The attention partition (live above, done below), and their sort discipline: the live + list holds creation order, newest first, and activity never reorders it. A row + keeps its place from open until you mark it done, so the thing you were about to click + never jumps. Status lives in the row, not in position. Color is spent on exactly three + things: approval (act now), working (in motion, with elapsed), + failed (broken). Everything else rests unlabeled with a timestamp. + Unread is its own axis: a completed run you haven’t seen keeps a bright title and + a white dot until you visit. + +

Our flavor

+ Rows with dividers, never cards — two lines instead of their three by folding + status into line one. Projects stop being a tree: the scope filter narrows the list, and + every row names its project, which is what killed the rail — project context + travels with the thread now, so nothing needs to be traced back to a group header. + Pinned threads float to the top of the live list (an explicit act, so the move is + yours). “Done” instead of T3’s “Settle”: plainer, and it + reads correctly for agent work. Blocked or running work refuses Done — same rule + the deck already had: live work can’t be waved away. + +

Deliberately deferred

+ Snooze (T3’s wake/woke machinery is a whole feature; worth doing later, properly). + Auto-done on merged PRs and after idle weeks (needs settled server semantics first). + Multi-select. + +

Open questions for Will

+ 1. Collapsed state: with the rail gone, does the sidebar still icon-collapse to + something (project favicons only), or does it just hide? The auto-fold-when-squeezed + behavior needs a target.
+ 2. Done placement: tail at the bottom (shown), or behind a destinations-band entry so + the sidebar is only live work?
+ 3. Do the destinations (Chats etc.) survive above the thread list as-is? +
+
+
+ + From 8594f45dda0da198d33d993e464f4a64d7d99b55 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:22:54 -0400 Subject: [PATCH 02/18] Offer chips beside the dropdown, and answer in plain words Will answered the collapse question -- hidden entirely, same toggle as today -- and could not answer the quick-links one because it was asked in internal vocabulary. Rewritten the way the screen looks. The new choice on the table is how project switching feels: a compact dropdown at two clicks a switch, or a row of recent-project chips at one, each carrying its needs-you count. --- docs/design/sidebar-inbox.html | 69 ++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/design/sidebar-inbox.html b/docs/design/sidebar-inbox.html index 26121cfe..b3bcd22f 100644 --- a/docs/design/sidebar-inbox.html +++ b/docs/design/sidebar-inbox.html @@ -71,6 +71,14 @@ } .scope:hover { background: var(--hover); color: var(--fg); } .scope .chev { color: var(--fg-dim); font-size: 9px; } + .chips { display: flex; gap: 4px; padding: 0 4px 2px; overflow: hidden; } + .chip { + font-size: 11.5px; color: var(--fg-mid); padding: 3px 8px; border-radius: 5px; + white-space: nowrap; cursor: default; + } + .chip:hover { background: var(--hover); color: var(--fg); } + .chip.on { background: var(--active-row); color: var(--fg); } + .chip .n { color: var(--fg-dim); font-size: 10px; margin-left: 3px; font-family: var(--mono); } /* ── Section headers ────────────────────────── */ .sect { @@ -266,6 +274,37 @@

Inbox sidebar

+
Option B — project chips instead of the dropdown
+ +
Same rows — hover affordances forced
From 90493011e815efa48a09ca3f022dddd784be8b5e Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:29:03 -0400 Subject: [PATCH 03/18] Settle the sketch: chips win, Done keeps its names Chips replace the dropdown in the main frame, the second frame shows the scoped state, and Done rows get their project labels back at readable contrast. Will's objection to the competitor's settled tail was exact -- dimming every row to the same grey turns history into texture. The section header carries done-ness; the rows keep their identity. Add project lives in the trailing ellipsis chip, which is the project menu: overflow projects, then the rare actions. --- docs/design/sidebar-inbox.html | 49 +++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/docs/design/sidebar-inbox.html b/docs/design/sidebar-inbox.html index b3bcd22f..0f4d4247 100644 --- a/docs/design/sidebar-inbox.html +++ b/docs/design/sidebar-inbox.html @@ -144,10 +144,14 @@ .act-btn:hover { background: var(--hover); color: var(--fg); } /* ── Done tail ──────────────────────────────── */ + /* Done rows keep their identity. Flattening them all to one grey (T3's + treatment) turns the section into a texture; here the section header + carries "done" and each row keeps a readable name and its project. */ .done .row { padding: 6px 12px; } .done .l1 { gap: 8px; } - .done .title { font-weight: 400; color: var(--fg-mid); } + .done .title { font-weight: 400; color: rgb(232 231 227 / 0.78); } .done .row:hover .title { color: var(--fg); } + .done .proj { flex: none; font-size: 11px; color: rgb(232 231 227 / 0.42); } .more { padding: 7px 12px 12px; font-size: 11px; color: var(--fg-dim); border-top: 1px solid var(--border); @@ -172,14 +176,19 @@

Inbox sidebar

-
Proposed — resting state
+
Final direction — resting state
-
Option B — project chips instead of the dropdown
+
Chips, scoped to one project
From 4462727aace372e8a95ef6b88162a036856b1aae Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:38:37 -0400 Subject: [PATCH 04/18] Promote Add project out of the overflow A folder-plus pinned to the right end of the chips row: always visible, never scrolling away with the overflow, and unambiguous beside the compose pencil because it wears the folder. The ellipsis chip goes back to meaning only what it says -- projects that did not fit. --- docs/design/sidebar-inbox.html | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/design/sidebar-inbox.html b/docs/design/sidebar-inbox.html index 0f4d4247..578cb18f 100644 --- a/docs/design/sidebar-inbox.html +++ b/docs/design/sidebar-inbox.html @@ -79,6 +79,9 @@ .chip:hover { background: var(--hover); color: var(--fg); } .chip.on { background: var(--active-row); color: var(--fg); } .chip .n { color: var(--fg-dim); font-size: 10px; margin-left: 3px; font-family: var(--mono); } + .chip.add { margin-left: auto; padding: 3px 6px; color: var(--fg-dim); } + .chip.add:hover { color: var(--fg); background: var(--hover); } + .chip.add svg { display: block; } /* ── Section headers ────────────────────────── */ .sect { @@ -187,7 +190,8 @@

Inbox sidebar

All threadlines 2 badcode - + +
@@ -294,7 +298,7 @@

Inbox sidebar

All threadlines 2 badcode - +
Threads
@@ -371,11 +375,12 @@

Decided

Collapsing the sidebar hides it completely, same toggle as today; no icon strip. Chips over the dropdown. The Done list stays at the bottom of the sidebar. Done rows are not flattened to uniform grey: the section header says done, each row keeps a - readable title and its project name. The trailing “…” chip is the - project menu — projects that didn't fit as chips, then Add project. The old - quick-links row folds away: compose covers new-chat, and the Chats page link moves - into the “…” menu's footer alongside Add project (easily moved if - that feels buried). + readable title and its project name. Add project is a primary action: a + folder-plus pinned to the right end of the chips row, always visible — the + “…” chip appears only when projects overflow, and holds only + those. The old quick-links row folds away: compose covers new-chat, and the Chats + page link moves into the “…” menu's footer (easily moved if that + feels buried).

Switching projects: dropdown vs chips

“All projects” is one combined list — every project’s threads From ac4ae7bf7cf78f3b115e2e5f1c1ed566ccb59fc3 Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:48:06 -0400 Subject: [PATCH 05/18] Lay the inbox's foundations The lifecycle, the sorts, and the chips, as pure functions with the components still to come. Done is an override the user sets, never a state the thread has: it is resolved against the thread's live condition on every read, and the blockers outrank it in both directions. Work that is blocked on the user, in flight, or sent-but-not-yet-adopted refuses to hide -- that last one bounded by a grace window on both sides, because message timestamps originate on whichever device sent them and a clock ahead of this one would otherwise hold the state for the whole skew. New activity in a done thread pulls it back to the live list by the same resolution, with nobody having to un-mark anything. That is what makes Done safe to use freely. The live list sorts by creation and pins alone; its input type does not carry activity timestamps, so reordering it by activity is a compile error, not a temptation. Done rows sort by when the work ended, label and order from the same function so they cannot disagree. Along the way: failed sessions get a pill. They had none -- an errored thread showed the same nothing as a healthy idle one, and counted toward nobody's attention. --- apps/web/src/components/Sidebar.logic.test.ts | 121 ++++ apps/web/src/components/Sidebar.logic.ts | 236 ++++++- apps/web/src/uiStateStore.test.ts | 2 + apps/web/src/uiStateStore.ts | 85 ++- docs/design/sidebar-inbox.html | 588 ++++++++++++++---- 5 files changed, 896 insertions(+), 136 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 63aff431..90c5b883 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -27,6 +27,7 @@ import { sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; +import { buildProjectChips, isThreadDone, sortInboxThreads } from "./Sidebar.logic"; import { EnvironmentId, OrchestrationLatestTurn, @@ -1380,3 +1381,123 @@ describe("buildProjectHoverSummary", () => { expect(summary.lastActivityAt).toBeNull(); }); }); + +// ── Inbox lifecycle ────────────────────────────────────────────────── + +describe("inbox done lifecycle", () => { + const NOW = "2026-07-28T12:00:00.000Z"; + const base = { + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestUserMessageAt: null, + latestTurn: null, + }; + const doneMark = { state: "done", at: NOW } as const; + + it("refuses to hide work that is blocked on the user", () => { + // Hiding a pending approval defeats the approval: the agent waits forever + // on an answer the user can no longer see the question for. + expect(isThreadDone({ ...base, hasPendingApprovals: true }, doneMark, { now: NOW })).toBe( + false, + ); + expect(isThreadDone({ ...base, hasPendingUserInput: true }, doneMark, { now: NOW })).toBe( + false, + ); + }); + + it("refuses to hide a running thread, even explicitly marked", () => { + const running = { + ...base, + session: { status: "running" } as never, + }; + expect(isThreadDone(running, doneMark, { now: NOW })).toBe(false); + }); + + it("treats a just-sent message as pending work until a turn adopts it", () => { + // Between send and adoption there is no turn and no running session -- + // the gap where marking Done would hide a message about to run. + const queued = { + ...base, + latestUserMessageAt: "2026-07-28T11:59:30.000Z", + }; + expect(isThreadDone(queued, doneMark, { now: NOW })).toBe(false); + // Past the grace window the same shape is a failed start, not pending + // work; holding it undoneable forever would strand the thread. + expect( + isThreadDone({ ...base, latestUserMessageAt: "2026-07-28T11:00:00.000Z" }, doneMark, { + now: NOW, + }), + ).toBe(true); + }); + + it("lets a failed thread be marked done", () => { + // "I saw it, I'm done with it" must be expressible for failures, or the + // Done list can never hold anything that went wrong. + const failed = { ...base, session: { status: "error" } as never }; + expect(isThreadDone(failed, doneMark, { now: NOW })).toBe(true); + }); + + it("keeps a thread live without an explicit done mark", () => { + expect(isThreadDone(base, null, { now: NOW })).toBe(false); + expect(isThreadDone(base, { state: "active", at: NOW }, { now: NOW })).toBe(false); + }); +}); + +describe("sortInboxThreads", () => { + it("holds creation order and floats pins, nothing else", () => { + const rows = [ + { id: "old", createdAt: "2026-07-20T00:00:00.000Z", pinnedAt: null }, + { id: "pinned", createdAt: "2026-07-18T00:00:00.000Z", pinnedAt: "2026-07-27T00:00:00.000Z" }, + { id: "new", createdAt: "2026-07-27T00:00:00.000Z", pinnedAt: null }, + ]; + + // Activity timestamps are deliberately absent from the input type: the + // list must be un-reorderable by anything but creation and pinning. + expect(sortInboxThreads(rows).map((row) => row.id)).toEqual(["pinned", "new", "old"]); + }); +}); + +describe("buildProjectChips", () => { + const projects = [ + { key: "a", label: "alpha" }, + { key: "b", label: "beta" }, + { key: "c", label: "gamma" }, + { key: "d", label: "delta" }, + ]; + const activity = new Map([ + ["a", 400], + ["b", 300], + ["c", 200], + ["d", 100], + ]); + + it("orders by recent activity and carries needs-you counts", () => { + const { chips, overflow } = buildProjectChips({ + projects, + lastActivityMsByKey: activity, + needsYouCountByKey: new Map([["b", 2]]), + scopedKey: null, + maxChips: 3, + }); + + expect(chips.map((chip) => chip.key)).toEqual(["a", "b", "c"]); + expect(chips[1]?.needsYouCount).toBe(2); + expect(overflow.map((chip) => chip.key)).toEqual(["d"]); + }); + + it("pulls the scoped project out of the overflow", () => { + // Filtering by a project and watching its chip vanish into the ellipsis + // reads as the sidebar losing your place. + const { chips, overflow } = buildProjectChips({ + projects, + lastActivityMsByKey: activity, + needsYouCountByKey: new Map(), + scopedKey: "d", + maxChips: 3, + }); + + expect(chips.map((chip) => chip.key)).toEqual(["a", "b", "d"]); + expect(overflow.map((chip) => chip.key)).toEqual(["c"]); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 744168d5..2aa35d25 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -40,7 +40,8 @@ export interface ThreadStatusPill { | "Pending Approval" | "Awaiting Input" | "Plan Ready" - | "Background"; + | "Background" + | "Failed"; colorClass: string; dotClass: string; pulse: boolean; @@ -54,6 +55,7 @@ const THREAD_STATUS_PRIORITY: Record = { "Plan Ready": 2, Background: 2, Completed: 1, + Failed: 4, }; export const THREAD_STATUS_DOT_CLASSES = { @@ -62,6 +64,7 @@ export const THREAD_STATUS_DOT_CLASSES = { cyan: "bg-cyan-500 dark:bg-cyan-300/90", emerald: "bg-emerald-500 dark:bg-emerald-300/90", violet: "bg-violet-500 dark:bg-violet-300/90", + red: "bg-red-500 dark:bg-red-400/90", } as const; type ThreadStatusInput = Pick< @@ -386,6 +389,17 @@ export function resolveThreadStatusPill(input: { }; } + if (thread.session?.status === "error") { + // Failed threads previously showed nothing -- indistinguishable from + // healthy idle ones, which is the worst place for a failure to hide. + return { + label: "Failed", + colorClass: "text-red-600 dark:text-red-400/90", + dotClass: THREAD_STATUS_DOT_CLASSES.red, + pulse: false, + }; + } + const hasPlanReadyPrompt = !thread.hasPendingUserInput && thread.interactionMode === "plan" && @@ -478,6 +492,7 @@ export function excludeOnDeckThreads(input: { const NEEDS_USER_STATUSES: ReadonlySet = new Set([ "Pending Approval", "Awaiting Input", + "Failed", ]); /** True while the provider is working on the thread or waiting on the user. */ @@ -746,3 +761,222 @@ export function buildProjectHoverSummary(input: { lastActivityAt, }; } + +// ── Inbox lifecycle ────────────────────────────────────────────────── +// +// The sidebar is an inbox: one live list, a Done tail. "Done" is a client-side +// overlay in v1 -- an override the user sets, resolved against the thread's +// actual state. The rules below owe their shape to studying how the settle +// lifecycle goes wrong: the invariant that matters is that no override may +// hide work that is moving or blocked on the user. + +/** + * A queued turn start counts as pending work for at most this long. + * + * Between sending a message and a session adopting it, the work is invisible + * to every status check: no turn, no running session. Without a bound, a + * thread whose start failed would be permanently un-doneable; without the + * guard, marking Done in that gap would hide a message that is about to run. + */ +export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; + +type InboxLifecycleInput = Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" +>; + +/** + * A user message no turn has picked up yet: strictly newer than every + * timestamp on the latest turn, and within the adoption grace window. Bounded + * on both sides because message timestamps originate on whichever device sent + * them -- a clock ahead of this one would otherwise hold the queued state for + * the whole skew. + */ +export function hasQueuedTurnStart( + thread: InboxLifecycleInput, + options: { readonly now: string }, +): boolean { + if (thread.latestUserMessageAt == null) return false; + // A failed start is already visible as the Failed pill; holding the queued + // state too would make the thread un-doneable while it screams red. + if (thread.session?.status === "error") return false; + const messageAt = Date.parse(thread.latestUserMessageAt); + if (Number.isNaN(messageAt)) return false; + const nowMs = Date.parse(options.now); + if (Number.isNaN(nowMs)) return false; + if (Math.abs(nowMs - messageAt) > QUEUED_TURN_START_GRACE_MS) return false; + const turn = thread.latestTurn; + if (turn === null) return true; + return [turn.requestedAt, turn.startedAt, turn.completedAt].every( + (candidate) => candidate == null || Date.parse(candidate) < messageAt, + ); +} + +/** + * Whether Done is allowed right now. Work that is moving, blocked on the + * user, or queued cannot be waved away: hiding a pending approval defeats + * the approval, and hiding a running turn hides where its result will land. + * A failed thread CAN be marked done -- that is "I saw it, I'm done with it". + */ +export function canMarkThreadDone( + thread: InboxLifecycleInput, + options: { readonly now: string }, +): boolean { + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + // The same in-flight resolution the status pill uses, so "can't be marked + // done" and "shows as working" can never disagree about what running means. + if (getThreadInFlightStatus(thread) !== null) return false; + if (hasQueuedTurnStart(thread, options)) return false; + return true; +} + +/** + * The user's explicit word on a thread's lifecycle, stamped when given. + * "active" exists so a reopened thread stays reopened once auto-done rules + * arrive; today it simply reads as not-done. + */ +export interface ThreadDoneOverride { + readonly state: "done" | "active"; + readonly at: string; +} + +/** + * Where a thread lives. The blockers outrank the override in both + * directions: new activity in a done thread pulls it back to the live list + * without anyone having to un-mark it, which is what makes Done safe to use + * freely. + */ +export function isThreadDone( + thread: InboxLifecycleInput, + override: ThreadDoneOverride | null | undefined, + options: { readonly now: string }, +): boolean { + if (!canMarkThreadDone(thread, options)) return false; + return override?.state === "done"; +} + +/** + * The live list holds still. Creation order, newest first; activity never + * reorders it, so a row keeps its place from open until it is marked done + * and the thing you were reaching for never jumps. Pins are the one + * exception, and they move only when you pin -- your action, your motion. + */ +export function sortInboxThreads< + T extends { readonly id: string; readonly createdAt: string; readonly pinnedAt: string | null }, +>(threads: readonly T[]): T[] { + return [...threads].toSorted((left, right) => { + if ((left.pinnedAt !== null) !== (right.pinnedAt !== null)) { + return left.pinnedAt !== null ? -1 : 1; + } + if (left.pinnedAt !== null && right.pinnedAt !== null) { + const byPin = + (toSortableTimestamp(right.pinnedAt) ?? 0) - (toSortableTimestamp(left.pinnedAt) ?? 0); + if (byPin !== 0) return byPin; + } + const byCreated = + (toSortableTimestamp(right.createdAt) ?? 0) - (toSortableTimestamp(left.createdAt) ?? 0); + return byCreated !== 0 ? byCreated : left.id.localeCompare(right.id); + }); +} + +type DoneSortInput = Pick< + SidebarThreadSummary, + "latestUserMessageAt" | "latestTurn" | "updatedAt" | "createdAt" +>; + +/** + * Done rows are history, so they order by when the work ended: the explicit + * mark when there is one, else the thread's last activity. Label and order + * both come from here so they can never disagree. + */ +export function resolveDoneTimestamp( + thread: DoneSortInput, + override: ThreadDoneOverride | null | undefined, +): string | null { + if (override?.state === "done" && !Number.isNaN(Date.parse(override.at))) { + return override.at; + } + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const candidate of [ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed) && parsed > latestMs) { + latest = candidate; + latestMs = parsed; + } + } + return latest ?? thread.updatedAt ?? thread.createdAt; +} + +export function sortDoneThreads( + threads: readonly T[], + overrideFor: (thread: T) => ThreadDoneOverride | null | undefined, +): T[] { + const timestampMs = (thread: T) => { + const timestamp = resolveDoneTimestamp(thread, overrideFor(thread)); + return timestamp === null ? 0 : (toSortableTimestamp(timestamp) ?? 0); + }; + return [...threads].toSorted( + (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), + ); +} + +// ── Project chips ──────────────────────────────────────────────────── + +export interface ProjectChipModel { + readonly key: string; + readonly label: string; + /** Threads in this project blocked on the user, shown on the chip. */ + readonly needsYouCount: number; +} + +/** + * Which projects earn a chip, in most-recently-active order. + * + * The scoped project always gets a chip even when its activity would not + * earn one: filtering by a project and then watching its chip vanish into + * the overflow reads as the sidebar losing your place. + */ +export function buildProjectChips(input: { + readonly projects: ReadonlyArray<{ readonly key: string; readonly label: string }>; + readonly lastActivityMsByKey: ReadonlyMap; + readonly needsYouCountByKey: ReadonlyMap; + readonly scopedKey: string | null; + readonly maxChips: number; +}): { readonly chips: ProjectChipModel[]; readonly overflow: ProjectChipModel[] } { + const toModel = (project: { key: string; label: string }): ProjectChipModel => ({ + key: project.key, + label: project.label, + needsYouCount: input.needsYouCountByKey.get(project.key) ?? 0, + }); + const ordered = [...input.projects] + .toSorted( + (left, right) => + (input.lastActivityMsByKey.get(right.key) ?? 0) - + (input.lastActivityMsByKey.get(left.key) ?? 0) || left.label.localeCompare(right.label), + ) + .map(toModel); + if (ordered.length <= input.maxChips) { + return { chips: ordered, overflow: [] }; + } + const chips = ordered.slice(0, input.maxChips); + const overflow = ordered.slice(input.maxChips); + if (input.scopedKey !== null && !chips.some((chip) => chip.key === input.scopedKey)) { + const scoped = overflow.find((chip) => chip.key === input.scopedKey); + if (scoped !== undefined) { + // The scoped project takes the last visible slot; the displaced chip + // joins the overflow where the scoped one came from. + const displaced = chips[chips.length - 1]!; + chips[chips.length - 1] = scoped; + const index = overflow.indexOf(scoped); + overflow[index] = displaced; + } + } + return { chips, overflow }; +} diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 64be831c..4c478981 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -30,6 +30,8 @@ function makeUiState(overrides: Partial = {}): UiState { threadLastVisitedAtById: {}, threadChangedFilesExpandedById: {}, onDeckThreadKeys: [], + doneThreadOverrides: {}, + inboxProjectScopeKey: null, onDeckDismissedThreadKeys: [], defaultAdvertisedEndpointKey: null, lastChatThreadRef: null, diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 3fd111aa..3152fee3 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -25,6 +25,8 @@ export interface PersistedUiState { threadChangedFilesExpandedById?: Record>; onDeckThreadKeys?: string[]; onDeckDismissedThreadKeys?: string[]; + doneThreadOverrides?: Record; + inboxProjectScopeKey?: string | null; } export interface UiProjectState { @@ -44,6 +46,17 @@ export interface UiOnDeckState { onDeckDismissedThreadKeys: string[]; } +export interface UiInboxState { + /** + * The user's explicit word on each thread's lifecycle, by scoped thread + * key. An override never decides on its own -- isThreadDone resolves it + * against the thread's live state, so activity blockers always win. + */ + doneThreadOverrides: Record; + /** Which project chip is selected; null is All. */ + inboxProjectScopeKey: string | null; +} + export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } @@ -53,7 +66,13 @@ export interface UiNavigationState { } export interface UiState - extends UiProjectState, UiThreadState, UiOnDeckState, UiEndpointState, UiNavigationState {} + extends + UiProjectState, + UiThreadState, + UiOnDeckState, + UiInboxState, + UiEndpointState, + UiNavigationState {} export interface SyncProjectInput { /** Physical project key (env + cwd). Used for manual sort order. */ @@ -75,6 +94,8 @@ const initialState: UiState = { threadChangedFilesExpandedById: {}, onDeckThreadKeys: [], onDeckDismissedThreadKeys: [], + doneThreadOverrides: {}, + inboxProjectScopeKey: null, defaultAdvertisedEndpointKey: null, lastChatThreadRef: null, }; @@ -123,12 +144,39 @@ function readPersistedState(): UiState { ), onDeckThreadKeys: sanitizePersistedKeyList(parsed.onDeckThreadKeys), onDeckDismissedThreadKeys: sanitizePersistedKeyList(parsed.onDeckDismissedThreadKeys), + doneThreadOverrides: sanitizePersistedDoneOverrides(parsed.doneThreadOverrides), + inboxProjectScopeKey: + typeof parsed.inboxProjectScopeKey === "string" && parsed.inboxProjectScopeKey.length > 0 + ? parsed.inboxProjectScopeKey + : null, }; } catch { return initialState; } } +function sanitizePersistedDoneOverrides( + value: PersistedUiState["doneThreadOverrides"], +): Record { + if (value === undefined || value === null || typeof value !== "object") { + return {}; + } + const sanitized: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if ( + typeof key === "string" && + key.length > 0 && + entry !== null && + typeof entry === "object" && + (entry.state === "done" || entry.state === "active") && + typeof entry.at === "string" + ) { + sanitized[key] = { state: entry.state, at: entry.at }; + } + } + return sanitized; +} + function sanitizePersistedKeyList(value: string[] | undefined): string[] { if (!Array.isArray(value)) { return []; @@ -228,6 +276,8 @@ export function persistState(state: UiState): void { threadChangedFilesExpandedById, onDeckThreadKeys: state.onDeckThreadKeys, onDeckDismissedThreadKeys: state.onDeckDismissedThreadKeys, + doneThreadOverrides: state.doneThreadOverrides, + inboxProjectScopeKey: state.inboxProjectScopeKey, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -539,6 +589,26 @@ export function syncOnDeck( }; } +/** + * Stamp the user's word on a thread's lifecycle. Pure and idempotent: marking + * a done thread done again refreshes nothing, so a double-click cannot move a + * row twice. + */ +export function setDoneOverride( + state: UiState, + threadKey: string, + override: { state: "done" | "active"; at: string }, +): UiState { + const current = state.doneThreadOverrides[threadKey]; + if (current !== undefined && current.state === override.state) { + return state; + } + return { + ...state, + doneThreadOverrides: { ...state.doneThreadOverrides, [threadKey]: override }, + }; +} + export function dismissFromOnDeck(state: UiState, threadKey: string): UiState { if (!state.onDeckThreadKeys.includes(threadKey)) { return state; @@ -762,6 +832,9 @@ interface UiStateStore extends UiState { syncThreads: (threads: readonly SyncThreadInput[]) => void; syncOnDeck: (threads: readonly OnDeckSyncInput[], activeThreadKey: string | null) => void; dismissFromOnDeck: (threadKey: string) => void; + markThreadDone: (threadKey: string, at: string) => void; + reopenThread: (threadKey: string, at: string) => void; + setInboxProjectScope: (projectKey: string | null) => void; markThreadVisited: (threadId: string, visitedAt?: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; @@ -788,6 +861,16 @@ export const useUiStateStore = create((set) => ({ syncOnDeck: (threads, activeThreadKey) => set((state) => syncOnDeck(state, threads, activeThreadKey)), dismissFromOnDeck: (threadKey) => set((state) => dismissFromOnDeck(state, threadKey)), + markThreadDone: (threadKey, at) => + set((state) => setDoneOverride(state, threadKey, { state: "done", at })), + reopenThread: (threadKey, at) => + set((state) => setDoneOverride(state, threadKey, { state: "active", at })), + setInboxProjectScope: (projectKey) => + set((state) => + state.inboxProjectScopeKey === projectKey + ? state + : { ...state, inboxProjectScopeKey: projectKey }, + ), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => diff --git a/docs/design/sidebar-inbox.html b/docs/design/sidebar-inbox.html index 578cb18f..caa0fe65 100644 --- a/docs/design/sidebar-inbox.html +++ b/docs/design/sidebar-inbox.html @@ -30,7 +30,11 @@ --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; --mono: ui-monospace, "SF Mono", Menlo, monospace; } - * { margin: 0; padding: 0; box-sizing: border-box; } + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } body { background: var(--bg); color: var(--fg); @@ -38,10 +42,31 @@ font-size: 13px; padding: 40px; } - h1 { font-size: 20px; font-weight: 600; margin-bottom: 6px; } - .sub { color: var(--fg-mid); margin-bottom: 28px; max-width: 640px; line-height: 1.5; } - .stage { display: flex; gap: 40px; align-items: flex-start; flex-wrap: wrap; } - .frame-label { font-size: 11px; color: var(--fg-dim); margin-bottom: 8px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.06em; } + h1 { + font-size: 20px; + font-weight: 600; + margin-bottom: 6px; + } + .sub { + color: var(--fg-mid); + margin-bottom: 28px; + max-width: 640px; + line-height: 1.5; + } + .stage { + display: flex; + gap: 40px; + align-items: flex-start; + flex-wrap: wrap; + } + .frame-label { + font-size: 11px; + color: var(--fg-dim); + margin-bottom: 8px; + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 0.06em; + } .sidebar { width: 288px; @@ -55,126 +80,346 @@ } /* ── Top chrome ─────────────────────────────── */ - .chrome { padding: 10px 10px 6px; display: flex; flex-direction: column; gap: 6px; } - .searchrow { display: flex; align-items: center; gap: 8px; } + .chrome { + padding: 10px 10px 6px; + display: flex; + flex-direction: column; + gap: 6px; + } + .searchrow { + display: flex; + align-items: center; + gap: 8px; + } .search { - flex: 1; display: flex; align-items: center; gap: 7px; - padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; - color: var(--fg-dim); font-size: 12px; + flex: 1; + display: flex; + align-items: center; + gap: 7px; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--fg-dim); + font-size: 12px; + } + .search .kbd { + margin-left: auto; + font-family: var(--mono); + font-size: 10px; + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 4px; + } + .iconbtn { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border-radius: 6px; + color: var(--fg-mid); + } + .iconbtn:hover { + background: var(--hover); + color: var(--fg); } - .search .kbd { margin-left: auto; font-family: var(--mono); font-size: 10px; border: 1px solid var(--border); border-radius: 3px; padding: 1px 4px; } - .iconbtn { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 6px; color: var(--fg-mid); } - .iconbtn:hover { background: var(--hover); color: var(--fg); } .scope { - display: flex; align-items: center; gap: 6px; padding: 4px 6px; - color: var(--fg-mid); font-size: 12px; border-radius: 6px; width: fit-content; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + color: var(--fg-mid); + font-size: 12px; + border-radius: 6px; + width: fit-content; + } + .scope:hover { + background: var(--hover); + color: var(--fg); + } + .scope .chev { + color: var(--fg-dim); + font-size: 9px; + } + .chips { + display: flex; + gap: 4px; + padding: 0 4px 2px; + overflow: hidden; } - .scope:hover { background: var(--hover); color: var(--fg); } - .scope .chev { color: var(--fg-dim); font-size: 9px; } - .chips { display: flex; gap: 4px; padding: 0 4px 2px; overflow: hidden; } .chip { - font-size: 11.5px; color: var(--fg-mid); padding: 3px 8px; border-radius: 5px; - white-space: nowrap; cursor: default; - } - .chip:hover { background: var(--hover); color: var(--fg); } - .chip.on { background: var(--active-row); color: var(--fg); } - .chip .n { color: var(--fg-dim); font-size: 10px; margin-left: 3px; font-family: var(--mono); } - .chip.add { margin-left: auto; padding: 3px 6px; color: var(--fg-dim); } - .chip.add:hover { color: var(--fg); background: var(--hover); } - .chip.add svg { display: block; } + font-size: 11.5px; + color: var(--fg-mid); + padding: 3px 8px; + border-radius: 5px; + white-space: nowrap; + cursor: default; + } + .chip:hover { + background: var(--hover); + color: var(--fg); + } + .chip.on { + background: var(--active-row); + color: var(--fg); + } + .chip .n { + color: var(--fg-dim); + font-size: 10px; + margin-left: 3px; + font-family: var(--mono); + } + .chip.add { + margin-left: auto; + padding: 3px 6px; + color: var(--fg-dim); + } + .chip.add:hover { + color: var(--fg); + background: var(--hover); + } + .chip.add svg { + display: block; + } /* ── Section headers ────────────────────────── */ .sect { - display: flex; align-items: baseline; gap: 8px; - padding: 12px 12px 5px; font-size: 11px; color: var(--fg-dim); - font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.06em; + display: flex; + align-items: baseline; + gap: 8px; + padding: 12px 12px 5px; + font-size: 11px; + color: var(--fg-dim); + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 0.06em; + } + .sect .count { + margin-left: auto; + text-transform: none; + letter-spacing: 0; + } + .sect .needs { + color: var(--act); } - .sect .count { margin-left: auto; text-transform: none; letter-spacing: 0; } - .sect .needs { color: var(--act); } /* ── Thread rows ────────────────────────────── */ - ul { list-style: none; } + ul { + list-style: none; + } .row { position: relative; padding: 7px 12px 8px; border-top: 1px solid var(--border); cursor: default; } - .row:hover { background: var(--hover); } - .row.current { background: var(--active-row); } - .l1 { display: flex; align-items: center; gap: 7px; min-width: 0; } - .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; } - .dot.act { background: var(--act); } - .dot.motion { background: var(--motion); } - .dot.broken { background: var(--broken); } - .dot.unread { background: var(--fg); } - .pin { color: var(--fg-dim); font-size: 10px; flex: none; } + .row:hover { + background: var(--hover); + } + .row.current { + background: var(--active-row); + } + .l1 { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + } + .dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex: none; + } + .dot.act { + background: var(--act); + } + .dot.motion { + background: var(--motion); + } + .dot.broken { + background: var(--broken); + } + .dot.unread { + background: var(--fg); + } + .pin { + color: var(--fg-dim); + font-size: 10px; + flex: none; + } .title { - flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-weight: 500; color: rgb(232 231 227 / 0.9); font-size: 13px; - } - .title.bright { color: var(--fg); } - .title.dim { font-weight: 400; color: var(--fg-mid); } - .stat { flex: none; font-size: 11px; font-family: var(--mono); color: var(--fg-dim); } - .stat.act { color: var(--act); } - .stat.motion { color: var(--motion); } - .stat.broken { color: var(--broken); } + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: rgb(232 231 227 / 0.9); + font-size: 13px; + } + .title.bright { + color: var(--fg); + } + .title.dim { + font-weight: 400; + color: var(--fg-mid); + } + .stat { + flex: none; + font-size: 11px; + font-family: var(--mono); + color: var(--fg-dim); + } + .stat.act { + color: var(--act); + } + .stat.motion { + color: var(--motion); + } + .stat.broken { + color: var(--broken); + } .l2 { - display: flex; align-items: center; gap: 6px; min-width: 0; - margin-top: 2px; padding-left: 14px; - font-size: 11px; color: var(--fg-dim); - } - .l2 .proj { color: rgb(232 231 227 / 0.5); } - .l2 .sep { color: rgb(232 231 227 / 0.22); } - .l2 .branch { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; } - .l2 .right { margin-left: auto; display: flex; align-items: center; gap: 6px; flex: none; } - .l2 .diff { font-family: var(--mono); font-size: 10px; } - .l2 .plus { color: rgb(120 200 130); } - .l2 .minus { color: rgb(230 120 110); } - .glyph { width: 12px; height: 12px; opacity: 0.55; flex: none; } + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-top: 2px; + padding-left: 14px; + font-size: 11px; + color: var(--fg-dim); + } + .l2 .proj { + color: rgb(232 231 227 / 0.5); + } + .l2 .sep { + color: rgb(232 231 227 / 0.22); + } + .l2 .branch { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--mono); + font-size: 10.5px; + } + .l2 .right { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; + flex: none; + } + .l2 .diff { + font-family: var(--mono); + font-size: 10px; + } + .l2 .plus { + color: rgb(120 200 130); + } + .l2 .minus { + color: rgb(230 120 110); + } + .glyph { + width: 12px; + height: 12px; + opacity: 0.55; + flex: none; + } /* Hover actions replace the right meta (shown forced on one row) */ .acts { - position: absolute; right: 8px; top: 6px; display: none; gap: 2px; - background: var(--panel); padding-left: 8px; + position: absolute; + right: 8px; + top: 6px; + display: none; + gap: 2px; + background: var(--panel); + padding-left: 8px; + } + .row.show-acts .acts { + display: flex; + } + .row.show-acts .stat { + visibility: hidden; } - .row.show-acts .acts { display: flex; } - .row.show-acts .stat { visibility: hidden; } .act-btn { - font-size: 11px; color: var(--fg-mid); padding: 2px 7px; border-radius: 4px; - display: flex; align-items: center; gap: 4px; + font-size: 11px; + color: var(--fg-mid); + padding: 2px 7px; + border-radius: 4px; + display: flex; + align-items: center; + gap: 4px; + } + .act-btn:hover { + background: var(--hover); + color: var(--fg); } - .act-btn:hover { background: var(--hover); color: var(--fg); } /* ── Done tail ──────────────────────────────── */ /* Done rows keep their identity. Flattening them all to one grey (T3's treatment) turns the section into a texture; here the section header carries "done" and each row keeps a readable name and its project. */ - .done .row { padding: 6px 12px; } - .done .l1 { gap: 8px; } - .done .title { font-weight: 400; color: rgb(232 231 227 / 0.78); } - .done .row:hover .title { color: var(--fg); } - .done .proj { flex: none; font-size: 11px; color: rgb(232 231 227 / 0.42); } + .done .row { + padding: 6px 12px; + } + .done .l1 { + gap: 8px; + } + .done .title { + font-weight: 400; + color: rgb(232 231 227 / 0.78); + } + .done .row:hover .title { + color: var(--fg); + } + .done .proj { + flex: none; + font-size: 11px; + color: rgb(232 231 227 / 0.42); + } .more { - padding: 7px 12px 12px; font-size: 11px; color: var(--fg-dim); + padding: 7px 12px 12px; + font-size: 11px; + color: var(--fg-dim); border-top: 1px solid var(--border); } - .more:hover { color: var(--fg-mid); } + .more:hover { + color: var(--fg-mid); + } /* ── Notes column ───────────────────────────── */ - .notes { max-width: 420px; font-size: 13px; line-height: 1.55; color: var(--fg-mid); } - .notes h2 { font-size: 13px; color: var(--fg); margin: 18px 0 4px; font-weight: 600; } - .notes h2:first-child { margin-top: 0; } - .notes code { font-family: var(--mono); font-size: 11.5px; color: var(--fg); } - .notes .q { color: var(--act); } + .notes { + max-width: 420px; + font-size: 13px; + line-height: 1.55; + color: var(--fg-mid); + } + .notes h2 { + font-size: 13px; + color: var(--fg); + margin: 18px 0 4px; + font-weight: 600; + } + .notes h2:first-child { + margin-top: 0; + } + .notes code { + font-family: var(--mono); + font-size: 11.5px; + color: var(--fg); + } + .notes .q { + color: var(--act); + }

Inbox sidebar

- Threads are the inbox. One live list ordered by when work began, a Done tail ordered by - when work ended, and the rail retired. Rows and hairline dividers, not cards: two lines, - ~46px, where T3 spends 78px on three. + Threads are the inbox. One live list ordered by when work began, a Done tail ordered by when + work ended, and the rail retired. Rows and hairline dividers, not cards: two lines, ~46px, + where T3 spends 78px on three.

@@ -191,11 +436,27 @@

Inbox sidebar

threadlines 2 badcode - + + +
-
Threads 2 need you · 6
+
+ Threads 2 need you · 6 +
  • @@ -206,7 +467,16 @@

    Inbox sidebar

    threadlines· feature/revert-preview -
    +214 −38
    +
    + +214 −38 + + +
  • @@ -218,7 +488,14 @@

    Inbox sidebar

    threadlines· feature/sidebar-polish -
    +
    + + + +
  • @@ -230,7 +507,13 @@

    Inbox sidebar

    badcode· feature/deck-sidebar -
    +26k −329
    +
    + +26k −329 + + +
  • @@ -242,7 +525,11 @@

    Inbox sidebar

    threadlines· fix/relay-backoff -
    +
    + + + +
  • @@ -254,7 +541,14 @@

    Inbox sidebar

    badcode· main -
    +
    + + + +
  • @@ -265,7 +559,11 @@

    Inbox sidebar

    threadlines· main -
    +
    + + + +
@@ -273,13 +571,22 @@

Inbox sidebar

Done 31
  • -
    Answer a notification with 202badcode1d
    +
    + Answer a notification with 202badcode1d +
  • -
    Spellcheck via native Chromiumthreadlines6d
    +
    + Spellcheck via native Chromiumthreadlines6d +
  • -
    Phone link relay chunkingthreadlines12d
    +
    + Phone link relay chunkingthreadlines12d +
Show 28 more…
@@ -288,7 +595,7 @@

Inbox sidebar

Chips, scoped to one project
-