From 7577b51a5e214eb7d822cb1753940d7d97d000ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:25:22 +1100 Subject: [PATCH 1/4] feat(web): threads babysitting pull requests fold into a Watching PRs section Threads idle between pull request watch wakes now sit in their own collapsed sidebar section instead of the Working section. They keep watching; a wake shows them in Working while the agent runs, and they return to the active list when the watch ends or the agent needs the user. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.drag.ts | 22 ++++- apps/web/src/components/Sidebar.logic.test.ts | 25 ++++-- apps/web/src/components/Sidebar.logic.ts | 14 +-- apps/web/src/components/Sidebar.tsx | 87 ++++++++++++++++--- .../components/sidebar/SidebarListMarkers.tsx | 10 ++- docs/user/thread-sidebar.md | 5 ++ .../src/state/threadInbox.test.ts | 41 +++++++++ .../client-runtime/src/state/threadInbox.ts | 16 ++++ .../orchestrationV2PendingBackgroundWork.ts | 8 +- 9 files changed, 195 insertions(+), 33 deletions(-) diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts index 76f2c9bf3a3ec..68fab0032a92d 100644 --- a/apps/web/src/components/Sidebar.drag.ts +++ b/apps/web/src/components/Sidebar.drag.ts @@ -16,6 +16,7 @@ type Layout = Parameters[0]; const isShelfHeader = (item: SidebarListItem | undefined) => item?.kind === "marker" && (item.marker === "working-header" || + item.marker === "watching-header" || item.marker === "snoozed-header" || item.marker === "settled-header"); @@ -56,7 +57,9 @@ export function createSidebarCollisionDetection( if (pointer.x >= boundary.left && pointer.x <= boundary.right) { if (pointer.y < previousY && pointer.y <= boundary.bottom) boundarySection = "pinned"; else if (pointer.y > previousY && pointer.y >= boundary.top) boundarySection = "active"; - const nextHeader = (["working-header", "snoozed-header", "settled-header"] as const) + const nextHeader = ( + ["working-header", "watching-header", "snoozed-header", "settled-header"] as const + ) .map((marker) => args.droppableContainers.find((container) => container.id === sidebarMarkerId(marker)), ) @@ -118,6 +121,7 @@ export function createSidebarSortingStrategy(input: { pinned: [], active: [], working: [], + watching: [], snoozed: [], settled: [], }; @@ -132,7 +136,12 @@ export function createSidebarSortingStrategy(input: { } continue; } - if (item.section === "pinned" || item.section === "active" || item.section === "working") + if ( + item.section === "pinned" || + item.section === "active" || + item.section === "working" || + item.section === "watching" + ) cardHeight ??= rects[index]?.height; else slimHeight ??= rects[index]?.height; if (item.key !== active.key) groups[item.section].push(item); @@ -180,6 +189,10 @@ export function createSidebarSortingStrategy(input: { marker("working-header"); projected.push(...groups.working); } + if (items.some((item) => item.kind === "marker" && item.marker === "watching-header")) { + marker("watching-header"); + projected.push(...groups.watching); + } if ( groups.snoozed.length > 0 || ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) && @@ -195,7 +208,10 @@ export function createSidebarSortingStrategy(input: { const rect = index === undefined ? undefined : rects[index]; const fallback = item.kind === "thread" && - (item.section === "pinned" || item.section === "active" || item.section === "working") + (item.section === "pinned" || + item.section === "active" || + item.section === "working" || + item.section === "watching") ? cardHeight : slimHeight; const moved = item.kind === "thread" && item.key === active.key; diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index e4fa68a65201b..2d1542adba796 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1148,12 +1148,16 @@ describe("Working shelf (beta)", () => { row("a2", "active"), marker("working-header"), row("w1", "working"), + marker("watching-header"), + row("pr1", "watching"), marker("settled-header"), row("s1", "settled"), ]; - it("never drops into the Working shelf, and keeps it out of the inbox order", () => { + it("never drops into the Working or Watching shelves, and keeps them out of the inbox order", () => { expect(resolveSidebarDropTarget(items, "a1", "w1")).toBeNull(); + expect(resolveSidebarDropTarget(items, "a1", "pr1")).toBeNull(); + expect(resolveSidebarDropVerb("active", "watching")).toBeNull(); expect(resolveSidebarDropTarget(items, "p1", "a2")).toEqual({ section: "active", pinnedOrder: [], @@ -1163,18 +1167,21 @@ describe("Working shelf (beta)", () => { }); it.each([ - ["pinned", "p1"], - ["active", "a2"], - ["settled", "s1"], + ["working", "pinned", "w1", "p1"], + ["working", "active", "w1", "a2"], + ["working", "settled", "w1", "s1"], + ["watching", "pinned", "pr1", "p1"], + ["watching", "active", "pr1", "a2"], + ["watching", "settled", "pr1", "s1"], ] as const)( - "does not move a Working thread into %s when its reference drag ends in the sidebar", - (section, overKey) => { - const target = resolveSidebarDropTarget(items, "w1", overKey); + "does not move a %s thread into %s when its reference drag ends in the sidebar", + (activeSection, section, activeKey, overKey) => { + const target = resolveSidebarDropTarget(items, activeKey, overKey); expect(target?.section).toBe(section); expect( planSidebarThreadDrop({ - activeKey: "w1", - activeSection: "working", + activeKey, + activeSection, target: target!, pinnedOrder: ["p1"], pinnedKeysById: new Map([["p1", "m"]]), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index efd8244f8c2b2..23d4ee56c67c1 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -162,9 +162,9 @@ export function useRetainedValue(key: string | null, value: T | null): T | nu export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) => args.isSorting ? defaultAnimateLayoutChanges(args) : false; -// Snoozed rows cannot accept drops without a wake time. Working rows only drag out as chat context. +// Snoozed rows cannot accept drops without a wake time. Working and watching rows only drag out as chat context. -export type SidebarSection = "pinned" | "active" | "working" | "snoozed" | "settled"; +export type SidebarSection = "pinned" | "active" | "working" | "watching" | "snoozed" | "settled"; export function resolveSidebarThreadSection(input: { readonly snoozed: boolean; @@ -185,6 +185,7 @@ export type SidebarListMarker = | "settled-placeholder" | "pinned-divider" | "working-header" + | "watching-header" | "snoozed-header" | "settled-header"; @@ -207,6 +208,7 @@ function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): if (item.kind !== "marker") continue; if (item.marker === "pinned-divider") section = "active"; else if (item.marker === "working-header") section = "working"; + else if (item.marker === "watching-header") section = "watching"; else if (item.marker === "snoozed-header") section = "snoozed"; else if (item.marker === "settled-header") section = "settled"; } @@ -230,7 +232,7 @@ export function resolveSidebarDropTarget( const moved = items.filter((_, index) => index !== activeIndex); moved.splice(overIndex, 0, items[activeIndex]!); const section = sectionAtSidebarSlot(moved, overIndex); - if (section === "working" || section === "snoozed") return null; + if (section === "working" || section === "watching" || section === "snoozed") return null; const pinnedOrder: string[] = []; const activeOrder: string[] = []; let currentSection: SidebarSection = "pinned"; @@ -239,6 +241,7 @@ export function resolveSidebarDropTarget( if (item.marker === "pinned-divider") currentSection = "active"; else if ( item.marker === "working-header" || + item.marker === "watching-header" || item.marker === "snoozed-header" || item.marker === "settled-header" ) @@ -278,7 +281,8 @@ export function resolveSidebarDropVerb( from: SidebarSection, to: SidebarSection | null, ): SidebarDropVerb | null { - if (to === null || to === from || to === "working" || to === "snoozed") return null; + if (to === null || to === from || to === "working" || to === "watching" || to === "snoozed") + return null; if (to === "pinned") return "pin"; if (to === "settled") return "settle"; if (from === "pinned") return "unpin"; @@ -337,7 +341,7 @@ export function planSidebarThreadDrop(input: { activeKeysById, activeReorderableKeys, } = input; - if (activeSection === "working") return { kind: "none" }; + if (activeSection === "working" || activeSection === "watching") return { kind: "none" }; if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) { return { kind: "none" }; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index de16be6d2dcfb..7fd72fa2b0409 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -31,6 +31,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { createInboxReturnTracker, + isThreadWatchingPullRequests, isThreadWorking, } from "@t3tools/client-runtime/state/thread-inbox"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; @@ -319,6 +320,7 @@ const SETTLED_TAIL_PAGE_COUNT = 25; const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar:snoozed-expanded"; const WORKING_SHELF_EXPANDED_KEY = "t3code:sidebar:working-expanded"; +const WATCHING_SHELF_EXPANDED_KEY = "t3code:sidebar:watching-expanded"; const inboxReturns = createInboxReturnTracker(); @@ -444,6 +446,11 @@ function useSidebarThreads(input: { false, Schema.Boolean, ); + const [watchingShelfExpanded, setWatchingShelfExpanded] = useLocalStorage( + WATCHING_SHELF_EXPANDED_KEY, + false, + Schema.Boolean, + ); // Shared with the old per-project sidebar, so folders keep their open state. const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); @@ -468,8 +475,14 @@ function useSidebarThreads(input: { clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); - const { pinnedThreads, activeThreads, workingThreads, snoozedThreads, settledThreads } = - partition; + const { + pinnedThreads, + activeThreads, + workingThreads, + watchingThreads, + snoozedThreads, + settledThreads, + } = partition; // Older settled threads load in pages; the shelf lists them only as far as they are loaded. const olderSettledShelf = useOlderSettledShelf({ scopes: olderSettledScopes([...serverConfigs.keys()], scopedProjectKeys), @@ -502,6 +515,9 @@ function useSidebarThreads(input: { const visibleWorkingThreads = workingShelfExpanded ? workingThreads : routeThreadOnly(workingThreads, routeRowThreadKey); + const visibleWatchingThreads = watchingShelfExpanded + ? watchingThreads + : routeThreadOnly(watchingThreads, routeRowThreadKey); const projectFolders = folderGroups === null ? null @@ -528,6 +544,7 @@ function useSidebarThreads(input: { ? activeThreads : projectFolders.flatMap((folder) => folder.threads)), ...visibleWorkingThreads, + ...visibleWatchingThreads, ...visibleSnoozedThreads, ...renderedSettledThreads, ]; @@ -536,6 +553,7 @@ function useSidebarThreads(input: { [pinnedThreads, "pinned"], [activeThreads, "active"], [workingThreads, "working"], + [watchingThreads, "watching"], [snoozedThreads, "snoozed"], [settledThreads, "settled"], ] as const) { @@ -549,6 +567,7 @@ function useSidebarThreads(input: { ...pinnedThreads, ...activeThreads, ...workingThreads, + ...watchingThreads, ...snoozedThreads, ...settledThreads, ], @@ -567,6 +586,8 @@ function useSidebarThreads(input: { toggleSnoozedShelf: () => setSnoozedShelfExpanded((value) => !value), workingShelfExpanded, toggleWorkingShelf: () => setWorkingShelfExpanded((value) => !value), + watchingShelfExpanded, + toggleWatchingShelf: () => setWatchingShelfExpanded((value) => !value), projectFolders, toggleProjectFolder: (group: SidebarProjectSnapshot) => { const keys = sidebarFolderExpansionKeys(group); @@ -575,6 +596,7 @@ function useSidebarThreads(input: { renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, + visibleWatchingThreads, orderedThreadKeys: orderedThreads.map(threadShellKey), threadByKey: new Map(orderedThreads.map((thread) => [threadShellKey(thread), thread] as const)), settledThreadKeys: new Set(settledThreads.map(threadShellKey)), @@ -607,11 +629,16 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; - const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - workingShelfEnabled && - (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) - ? working - : active; + const watching: EnvironmentThreadShell[] = []; + const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => { + if ( + !workingShelfEnabled || + !(manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) + ) { + return active; + } + return isThreadWatchingPullRequests(thread) ? watching : working; + }; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const draggable = new Set(); @@ -683,6 +710,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), workingThreads: sortWorkingThreadsBySend(working), + watchingThreads: sortWorkingThreadsBySend(watching), snoozedThreads: snoozed.toSorted( (left, right) => firstValidTimestampMs(left.snoozedUntil ?? null) - @@ -1244,6 +1272,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeReorderableThreadKeys, activeThreads, workingThreads, + watchingThreads, snoozedThreads, settledThreads, snoozeNow, @@ -1259,11 +1288,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve toggleSnoozedShelf, workingShelfExpanded, toggleWorkingShelf, + watchingShelfExpanded, + toggleWatchingShelf, projectFolders, toggleProjectFolder, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, + visibleWatchingThreads, orderedThreadKeys, threadByKey, settledThreadKeys, @@ -1652,7 +1684,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; }; - const isContextDrag = dragState?.contextDrag === true || dragState?.activeSection === "working"; + const isContextDrag = + dragState?.contextDrag === true || + dragState?.activeSection === "working" || + dragState?.activeSection === "watching"; const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null); const finishThreadDrag = (started: boolean) => { dragSensorRef.current = null; @@ -1863,6 +1898,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve pinnedThreads.length + activeThreads.length + workingThreads.length + + watchingThreads.length + snoozedThreads.length + settledCount === 0 @@ -1880,6 +1916,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve items.push({ kind: "marker", marker: "working-header" }); items.push(...rowsOf(visibleWorkingThreads, "working")); } + if (watchingThreads.length > 0) { + items.push({ kind: "marker", marker: "watching-header" }); + items.push(...rowsOf(visibleWatchingThreads, "watching")); + } if (snoozedThreads.length > 0) { items.push({ kind: "marker", marker: "snoozed-header" }); items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); @@ -3194,7 +3234,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve scopeThreadRef(thread.environmentId, thread.id), ); const isCard = - section === "active" || section === "pinned" || section === "working"; + section === "active" || + section === "pinned" || + section === "working" || + section === "watching"; const rowVariant = folderRow ? folderRowsMultiline ? "folder-multiline" @@ -3218,6 +3261,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve navigationParent={ folderRow ?? (section === "working" || + section === "watching" || section === "snoozed" || section === "settled" ? `shelf:${section}` @@ -3404,12 +3448,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve />, ); break; + case "watching-header": + items.push( + , + ); + break; case "snoozed-header": items.push( { ).toEqual(["sent-last", "sent-first", "launched"]); }); }); + +describe("isThreadWatchingPullRequests", () => { + const watch = { + taskId: "pull-request-watch:github.com/acme/app#1", + kind: "monitor" as const, + awaited: true as const, + }; + const parked = ( + status: "idle" | "running", + pendingBackgroundTasks: ReadonlyArray< + typeof watch | { taskId: string; kind: "subagent" | "command" } + >, + ) => ({ + runtime: { + status, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + pendingBackgroundTasks, + }); + + it("holds a thread parked only on pull request watches", () => { + expect(isThreadWatchingPullRequests(parked("idle", [watch]))).toBe(true); + // A dev server the agent left running does not hold the thread, so it does not count. + expect( + isThreadWatchingPullRequests(parked("idle", [watch, { taskId: "dev", kind: "command" }])), + ).toBe(true); + }); + + it("leaves threads that are running or waiting on other work as working", () => { + expect(isThreadWatchingPullRequests(parked("running", [watch]))).toBe(false); + expect( + isThreadWatchingPullRequests(parked("idle", [watch, { taskId: "child", kind: "subagent" }])), + ).toBe(false); + expect(isThreadWatchingPullRequests(parked("idle", []))).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index de9cd822398d0..abb6b2c7fea49 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -1,4 +1,9 @@ // @effect-diagnostics globalTimers:off - Shelf placement timers run in web and native clients, outside an Effect runtime. +import { + backgroundWorkHoldsCompletion, + isPullRequestWatchTask, +} from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; + import { threadRuntimeIsActive, type EnvironmentThreadShell } from "./models.ts"; import { toSortableTimestamp } from "./threadSort.ts"; import { effectiveSnoozed } from "./threadSettled.ts"; @@ -30,6 +35,17 @@ export function isThreadWorking(thread: WorkingThreadInput): boolean { return !(thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled); } +/** For a working thread: parked between wakes with only pull request watches holding it. */ +export function isThreadWatchingPullRequests( + thread: Pick, +): boolean { + if (thread.runtime?.status !== "idle") return false; + const holding = thread.pendingBackgroundTasks.filter((task) => + backgroundWorkHoldsCompletion([task]), + ); + return holding.length > 0 && holding.every(isPullRequestWatchTask); +} + type InboxThreadInput = Pick< EnvironmentThreadShell, "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun" diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 6dc635b5153a6..9c006c0149d05 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -289,6 +289,12 @@ export function derivePendingBackgroundWork(input: { return Array.from(byTaskId.values()); } +const PULL_REQUEST_WATCH_TASK_PREFIX = "pull-request-watch:"; + +export function isPullRequestWatchTask(task: Pick): boolean { + return task.taskId.startsWith(PULL_REQUEST_WATCH_TASK_PREFIX); +} + function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { @@ -302,7 +308,7 @@ function pullRequestWatchTasks( { // Links store their normalized identity, and this id only has to be unique within the // thread, so it reads those fields instead of parsing the URL. - taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, + taskId: `${PULL_REQUEST_WATCH_TASK_PREFIX}${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, awaited: true as const, From baae7a61d925057c03b209e50455910be830653e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 04:47:32 +0000 Subject: [PATCH 2/4] fix(web): keep guest requests visible outside watching shelf --- apps/web/src/components/Sidebar.logic.test.ts | 6 ++++-- apps/web/src/components/Sidebar.logic.ts | 5 ++++- apps/web/src/components/Sidebar.tsx | 9 ++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1464570063d7d..4c81e10cf0096 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1107,7 +1107,7 @@ describe("Working shelf (beta)", () => { }, ); - it("lists a running thread outside the shelf while its environment is disconnected", () => { + it("keeps disconnected threads and pending guest requests outside the shelves", () => { const thread = { ...idle, runtime, environmentId: localEnvironmentId }; const shelve = (disconnected: ReadonlyArray, enabled = true) => belongsInSidebarWorkingShelf(thread, { @@ -1125,7 +1125,9 @@ describe("Working shelf (beta)", () => { const watching = { ...waiting, environmentId: localEnvironmentId, - pendingBackgroundTasks: [{ ...backgroundTask, taskId: "pull-request-watch:github.com/acme/app#1" }], + pendingBackgroundTasks: [ + { ...backgroundTask, taskId: "pull-request-watch:github.com/acme/app#1" }, + ], pendingGuestMessageCount: 1, }; expect(isSidebarThreadWorking(watching)).toBe(true); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4bdec91aa11fa..97cb50da77e9e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -193,7 +193,9 @@ export function isSidebarEnvironmentDisconnected( return phase !== "connecting" && (phase !== "connected" || !shellIsLive); } -export function belongsInSidebarWorkingShelf( +export function belongsInSidebarWorkingShelf< + T extends { readonly environmentId: EnvironmentId; readonly pendingGuestMessageCount?: number }, +>( thread: T, input: { readonly enabled: boolean; @@ -203,6 +205,7 @@ export function belongsInSidebarWorkingShelf 0; // Cache on both fields so layout-key changes trigger the effect even when animate is unchanged. const listMotionPass = { layoutKey: listMotionLayoutKey, animate: listMotionAnimate }; - const shelfExpansionKey = [workingShelfExpanded, snoozedShelfExpanded, settledShelfExpanded].join( - ":", - ); + const shelfExpansionKey = [ + workingShelfExpanded, + watchingShelfExpanded, + snoozedShelfExpanded, + settledShelfExpanded, + ].join(":"); const previousShelfExpansionKeyRef = useRef(shelfExpansionKey); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the list motion controller to animate or not after each layout-affecting commit useLayoutEffect(() => { From acdb88200b8a0d25f35c8949fe4030ea2b3cce56 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 05:00:10 +0000 Subject: [PATCH 3/4] test(web): fix watching shelf test after merging main --- apps/web/src/components/Sidebar.logic.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4f5d0c875a703..b526406547bff 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1010,7 +1010,7 @@ describe("Working shelf (beta)", () => { }); it("keeps pending guest requests outside the shelves", () => { - const thread = { ...idle, runtime, environmentId: localEnvironmentId }; + const thread = { ...idle, runtime, pendingGuestMessageCount: 0 }; const shelve = (enabled = true) => belongsInSidebarWorkingShelf(thread, { enabled, isWorking: isSidebarThreadWorking }); From ac26e51a6375d3366e574bd3284ab72976b31849 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 05:24:45 +0000 Subject: [PATCH 4/4] fix(client-runtime): guest requests return working threads to the inbox --- apps/web/src/components/Sidebar.logic.test.ts | 23 ------------------- apps/web/src/components/Sidebar.logic.ts | 9 -------- apps/web/src/components/Sidebar.tsx | 7 ++---- .../src/state/threadInbox.test.ts | 8 +++++++ .../client-runtime/src/state/threadInbox.ts | 2 ++ 5 files changed, 12 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index b526406547bff..4c75c521e3aa9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,7 +6,6 @@ import { describe, expect, it } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { - belongsInSidebarWorkingShelf, shouldCreateNewThreadInCurrentProject, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, @@ -1009,28 +1008,6 @@ describe("Working shelf (beta)", () => { ).toBe(false); }); - it("keeps pending guest requests outside the shelves", () => { - const thread = { ...idle, runtime, pendingGuestMessageCount: 0 }; - const shelve = (enabled = true) => - belongsInSidebarWorkingShelf(thread, { enabled, isWorking: isSidebarThreadWorking }); - - expect(shelve()).toBe(true); - expect(shelve(false)).toBe(false); - - const watching = { - ...waiting, - environmentId: localEnvironmentId, - pendingBackgroundTasks: [ - { ...backgroundTask, taskId: "pull-request-watch:github.com/acme/app#1" }, - ], - pendingGuestMessageCount: 1, - }; - expect(isSidebarThreadWorking(watching)).toBe(true); - expect( - belongsInSidebarWorkingShelf(watching, { enabled: true, isWorking: isSidebarThreadWorking }), - ).toBe(false); - }); - describe("sortInboxThreadsByReturn", () => { const thread = ( id: string, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 25a417c80e030..6807db635ddc7 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -184,15 +184,6 @@ export function resolveSidebarThreadSection(input: { return "active"; } -export function belongsInSidebarWorkingShelf< - T extends { readonly pendingGuestMessageCount?: number }, ->( - thread: T, - input: { readonly enabled: boolean; readonly isWorking: (thread: T) => boolean }, -): boolean { - return input.enabled && (thread.pendingGuestMessageCount ?? 0) === 0 && input.isWorking(thread); -} - const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; export type SidebarListMarker = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b5c4d46db6b05..c111e9f20bc99 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -187,7 +187,6 @@ import { deleteSelectedThreadEntries, filterSidebarProjectScopeItems, firstValidTimestampMs, - belongsInSidebarWorkingShelf, isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, @@ -650,10 +649,8 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const watching: EnvironmentThreadShell[] = []; const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => { if ( - !belongsInSidebarWorkingShelf(thread, { - enabled: workingShelfEnabled, - isWorking: manuallyMoved ? isThreadWorking : inboxObservation.isWorking, - }) + !workingShelfEnabled || + !(manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) ) { return active; } diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index a8fb06878367a..01076217da1de 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -120,6 +120,14 @@ describe("createInboxReturnTracker", () => { expect(vi.getTimerCount()).toBe(0); }); + it("returns a running thread at once when a guest request arrives", () => { + const tracker = createInboxReturnTracker(); + tracker.observe([thread("a", true)]); + tracker.observe([{ ...thread("a", true), pendingGuestMessageCount: 1 }]); + expect(tracker.isWorking(thread("a", true))).toBe(false); + expect(tracker.returnedAt(thread("a", true))).toBe(10_000); + }); + it.each(["interrupted", "cancelled", "rolled_back"] as const)( "holds a %s run, because the server can still settle it", (status) => { diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 46b717618ed10..23771a53aa1af 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -14,6 +14,7 @@ type WorkingThreadInput = Pick< | "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" + | "pendingGuestMessageCount" | "interactionMode" | "latestRun" | "runtime" @@ -22,6 +23,7 @@ type WorkingThreadInput = Pick< export function isThreadWorking(thread: WorkingThreadInput): boolean { if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.pendingSubagentRequest != null) return false; + if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false; const run = thread.latestRun; const runSettled =