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 d1d045ed41a35..258a09cb6daf7 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1114,12 +1114,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: [], @@ -1129,18 +1133,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 7f60c1d35215a..82a8ab0d61895 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -187,9 +187,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; @@ -210,6 +210,7 @@ export type SidebarListMarker = | "settled-placeholder" | "pinned-divider" | "working-header" + | "watching-header" | "snoozed-header" | "settled-header"; @@ -232,6 +233,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"; } @@ -255,7 +257,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"; @@ -264,6 +266,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" ) @@ -303,7 +306,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"; @@ -388,7 +392,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 a94b9e9497c6b..d82109bc03c07 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -32,6 +32,7 @@ import { import { asLaunchingThread, createInboxReturnTracker, + isThreadWatchingPullRequests, isThreadWorking, } from "@t3tools/client-runtime/state/thread-inbox"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; @@ -333,6 +334,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(); @@ -465,6 +467,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); @@ -490,8 +497,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), @@ -524,6 +537,9 @@ function useSidebarThreads(input: { const visibleWorkingThreads = workingShelfExpanded ? workingThreads : routeThreadOnly(workingThreads, routeRowThreadKey); + const visibleWatchingThreads = watchingShelfExpanded + ? watchingThreads + : routeThreadOnly(watchingThreads, routeRowThreadKey); const projectFolders = folderGroups === null ? null @@ -550,6 +566,7 @@ function useSidebarThreads(input: { ? activeThreads : projectFolders.flatMap((folder) => folder.threads)), ...visibleWorkingThreads, + ...visibleWatchingThreads, ...visibleSnoozedThreads, ...renderedSettledThreads, ]; @@ -558,6 +575,7 @@ function useSidebarThreads(input: { [pinnedThreads, "pinned"], [activeThreads, "active"], [workingThreads, "working"], + [watchingThreads, "watching"], [snoozedThreads, "snoozed"], [settledThreads, "settled"], ] as const) { @@ -571,6 +589,7 @@ function useSidebarThreads(input: { ...pinnedThreads, ...activeThreads, ...workingThreads, + ...watchingThreads, ...snoozedThreads, ...settledThreads, ], @@ -589,6 +608,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); @@ -597,6 +618,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)), @@ -629,11 +651,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 { draggableThreadKeys, activeReorderableThreadKeys } = resolveSidebarReorderableThreadKeys({ @@ -704,6 +731,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) - @@ -1322,6 +1350,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeReorderableThreadKeys, activeThreads, workingThreads, + watchingThreads, snoozedThreads, settledThreads, snoozeNow, @@ -1337,11 +1366,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve toggleSnoozedShelf, workingShelfExpanded, toggleWorkingShelf, + watchingShelfExpanded, + toggleWatchingShelf, projectFolders, toggleProjectFolder, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, + visibleWatchingThreads, orderedThreadKeys, threadByKey, settledThreadKeys, @@ -1743,7 +1775,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const isContextDrag = projectFolders !== null || dragState?.contextDrag === true || - dragState?.activeSection === "working"; + dragState?.activeSection === "working" || + dragState?.activeSection === "watching"; const dragTargetSection = isContextDrag ? null : (dragState?.targetSection ?? null); const finishThreadDrag = (started: boolean) => { dragSensorRef.current = null; @@ -1965,6 +1998,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve pinnedThreads.length + activeThreads.length + workingThreads.length + + watchingThreads.length + snoozedThreads.length + settledCount === 0 @@ -1982,6 +2016,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")); @@ -2027,9 +2065,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve dragState === null && sidebarListItems.length + visibleDraftSessionCount > 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 -- records moved rows and updates list motion after each layout-affecting commit useLayoutEffect(() => { @@ -3297,6 +3338,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve pinnedThreads.length + activeThreads.length + workingThreads.length + + watchingThreads.length + snoozedThreads.length === 0 && (settledCount === 0 || scopedProjectGroups.length > 0) ? ( @@ -3378,7 +3420,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" @@ -3401,6 +3446,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve navigationParent={ folderRow ?? (section === "working" || + section === "watching" || section === "snoozed" || section === "settled" ? `shelf:${section}` @@ -3587,12 +3633,29 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve />, ); break; + case "watching-header": + items.push( + , + ); + break; case "snoozed-header": items.push( { 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) => { @@ -261,3 +270,43 @@ describe("sortWorkingThreadsBySend", () => { ).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 ac820acc9d1ea..543e79679a300 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"; @@ -9,6 +14,7 @@ type WorkingThreadInput = Pick< | "hasPendingApprovals" | "hasPendingUserInput" | "pendingSubagentRequest" + | "pendingGuestMessageCount" | "interactionMode" | "latestRun" | "runtime" @@ -17,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 = @@ -30,6 +37,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); +} + /** * A thread its own client is launching reads as preparing until its first run lands. * The server commits the thread before its run, so the list would otherwise place it twice. diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index dfdf9a24ace1e..f153fb4ee00e5 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -300,6 +300,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 { @@ -313,7 +319,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,