From 8381c48f13e5d65900ce2a504560c0106fd49d07 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 15:56:15 -0800 Subject: [PATCH 01/11] feat: keep commit, PR, and push in the Working section Generating a commit or PR, or pushing a branch, left the thread in the active list even though work was still running. Track the owning thread for stacked Git actions so web and mobile treat it as working until the action finishes. Co-authored-by: Cursor --- .../features/threads/thread-list-v2-items.tsx | 13 ++- .../src/features/threads/threadListV2.test.ts | 14 +++ .../src/features/threads/threadListV2.ts | 4 + .../features/threads/useThreadListV2.test.tsx | 5 + .../src/features/threads/useThreadListV2.ts | 13 ++- apps/web/src/components/Sidebar.logic.test.ts | 10 ++ apps/web/src/components/Sidebar.logic.ts | 10 +- apps/web/src/components/Sidebar.tsx | 17 ++- .../components/sidebar/SidebarThreadRow.tsx | 15 ++- docs/user/thread-sidebar.md | 5 +- .../src/state/vcsAction.test.ts | 101 ++++++++++++++++++ .../client-runtime/src/state/vcsAction.ts | 78 ++++++++++++++ 12 files changed, 268 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 77250f85a74c..b161ebf4054f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -25,11 +25,13 @@ import { formatWorkingDurationLabel, resolveThreadWorkingStartedAt, } from "@t3tools/client-runtime/state/models"; +import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import type { MenuAction } from "@react-native-menu/menu"; +import { useAtomValue } from "@effect/atom-react"; import { memo, useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, Text as RNText, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -53,6 +55,7 @@ import { resolvePendingDraftBranch, } from "../../state/pending-new-tasks-model"; import { useBranches } from "../../state/queries"; +import { vcsActionManager } from "../../state/vcs"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; import { resolveThreadPrBadgeAction, @@ -754,7 +757,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const selected = props.selected === true; const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); - const status = resolveThreadListV2Status(thread); + const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); + const stackedAction = stackedActionThreads.get(`${thread.environmentId}:${thread.id}`); + const sourceControlBusy = isThreadStackedActionRunning(thread, stackedActionThreads); + const status = resolveThreadListV2Status(thread, { sourceControlBusy }); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; @@ -762,6 +768,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); + const workingStartedAt = + resolveThreadWorkingStartedAt(thread) ?? + (stackedAction === undefined ? null : new Date(stackedAction.startedAtMs).toISOString()); const recede = shouldRecedeThreadRow({ status, selected }); const isRegeneratingTitle = thread.titleRegeneration != null; const rowAccessibilityLabel = `${thread.title}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; @@ -1242,7 +1251,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {statusLabel.label} {status === "working" ? ( ) : status === "waiting" ? ( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 786f1ce22911..7f1c81f870bc 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -187,6 +187,20 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status({ ...guest, hasPendingApprovals: true })).toBe("approval"); }); + it("treats an in-flight commit, push, or PR as working ahead of ready", () => { + const thread = presentThreadShell( + environmentId, + decodeThreadShell(makeRawThreadShell({ status: "completed" })), + ); + expect(resolveThreadListV2Status(thread, { sourceControlBusy: true })).toBe("working"); + expect( + resolveThreadListV2Status( + { ...thread, hasPendingUserInput: true }, + { sourceControlBusy: true }, + ), + ).toBe("input"); + }); + it.each([ { roster: "dev server", task: { kind: "command" }, status: "ready" }, { roster: "dev server watchdog", task: { kind: "monitor" }, status: "ready" }, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3245c1c281e0..4243b5904454 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -267,6 +267,7 @@ export function resolveThreadListV2Status( | "runtime" | "blockedOnThreadWait" >, + options?: { readonly sourceControlBusy?: boolean }, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; @@ -278,6 +279,9 @@ export function resolveThreadListV2Status( if ((thread.pendingGuestMessageCount ?? 0) > 0) { return "request"; } + if (options?.sourceControlBusy === true) { + return "working"; + } if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 7eac8955efa1..3bfe5f7e0a50 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -17,6 +17,11 @@ vi.mock("../../state/threads", () => ({ threadFamilies: { mountRows: () => () => {} }, })); vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined })); +vi.mock("../../state/vcs", () => ({ + vcsActionManager: { + stackedActionThreadsAtom: Atom.make(new Map()), + }, +})); it("reuses a large thread list across tab renders and updates when its inputs change", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index fcf1048d2b6a..26e29ff13278 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,10 +1,13 @@ import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; +import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useMemo, useSyncExternalStore } from "react"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { useOlderSettledShelf, useOlderSettledShelfGrowth } from "../../state/olderSettledShelf"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { vcsActionManager } from "../../state/vcs"; import { buildThreadListV2Items, buildThreadListV2ListItems, @@ -76,10 +79,16 @@ export function useThreadListV2(input: { threadListInboxReturns.subscribe, threadListInboxReturns.getSnapshot, ); + const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); const inbox = useMemo( () => observeInbox(workingShelfEnabled ? threads : null, inboxVersion), [threads, workingShelfEnabled, inboxVersion], ); + const isWorking = useMemo(() => { + const inboxIsWorking = inbox.isWorking; + return (thread: Parameters[0]) => + inboxIsWorking(thread) || isThreadStackedActionRunning(thread, stackedActionThreads); + }, [inbox.isWorking, stackedActionThreads]); // Compute move availability per section; per-row planning is quadratic. const moveAvailability = useMemo(() => { const sectionAvailability = (section: "pinned" | "active") => @@ -148,7 +157,7 @@ export function useThreadListV2(input: { workingShelfEnabled, workingShelfExpanded: input.workingShelfExpanded, inboxReturnAt: inbox.returnedAt, - isWorking: inbox.isWorking, + isWorking, snoozedShelfExpanded: input.snoozedShelfExpanded, settledShelfExpanded: input.settledShelfExpanded, selectedThreadKey: input.selectedThreadKey, @@ -180,7 +189,7 @@ export function useThreadListV2(input: { workingShelfEnabled, input.workingShelfExpanded, inbox.returnedAt, - inbox.isWorking, + isWorking, input.snoozedShelfExpanded, input.settledShelfExpanded, input.selectedThreadKey, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 0e3f47b7f9ed..d15afa51db2b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -310,6 +310,16 @@ describe("resolveSidebarThreadStatus", () => { }), ).toBe("waiting"); }); + + it("treats an in-flight commit, push, or PR as working ahead of ready", () => { + expect(resolveSidebarThreadStatus(idle, { sourceControlBusy: true })).toBe("working"); + expect( + resolveSidebarThreadStatus( + { ...idle, hasPendingUserInput: true }, + { sourceControlBusy: true }, + ), + ).toBe("input"); + }); }); describe("searchSidebarThreads", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef229d1424c3..3f3a719ccdb0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -858,7 +858,10 @@ type SidebarThreadStatusInput = Pick< | "blockedOnThreadWait" >; -export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { +export function resolveSidebarThreadStatus( + thread: SidebarThreadStatusInput, + options?: { readonly sourceControlBusy?: boolean }, +): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; } @@ -869,6 +872,9 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si if ((thread.pendingGuestMessageCount ?? 0) > 0) { return "request"; } + if (options?.sourceControlBusy === true) { + return "working"; + } if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } @@ -897,7 +903,7 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si export function resolveSidebarFolderDot( threads: ReadonlyArray, ): "attention" | "failed" | "unread" | "working" | null { - const statuses = new Set(threads.map(resolveSidebarThreadStatus)); + const statuses = new Set(threads.map((thread) => resolveSidebarThreadStatus(thread))); if ( statuses.has("input") || statuses.has("approval") || diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75b225a8b435..fe8e8c93f9ab 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -34,6 +34,8 @@ import { createInboxReturnTracker, isThreadWorking, } from "@t3tools/client-runtime/state/thread-inbox"; +import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; +import { vcsActionManager } from "~/state/vcs"; import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort"; import { nextShownOrder, @@ -356,6 +358,8 @@ interface SidebarThreadPartitionInput { readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; + /** Threads with an in-flight commit / push / PR on this client. */ + readonly stackedActionThreads: ReadonlyMap; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox // tracker's expiry version are inputs: a new key repartitions. @@ -445,6 +449,7 @@ function useSidebarThreads(input: { inboxReturns.subscribe, inboxReturns.getSnapshot, ); + const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); const nowMinute = useNowMinute(); // Minute-quantized clocks would delay second-precise snooze wakes by up to a minute. const snoozeWakeClock = useSnoozeWakeClock(threads); @@ -485,7 +490,8 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, - clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, + stackedActionThreads, + clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}:${[...stackedActionThreads.keys()].join(",")}`, }); const { pinnedThreads, activeThreads, workingThreads, snoozedThreads, settledThreads } = @@ -620,6 +626,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, + stackedActionThreads, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -627,11 +634,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; + const threadIsWorking = (thread: EnvironmentThreadShell, manuallyMoved = false) => + (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) || + isThreadStackedActionRunning(thread, stackedActionThreads); const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => - workingShelfEnabled && - (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) - ? working - : active; + workingShelfEnabled && threadIsWorking(thread, manuallyMoved) ? working : active; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; const { draggableThreadKeys, activeReorderableThreadKeys } = resolveSidebarReorderableThreadKeys({ diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 3a50e76d06bd..ed1a32b8ffd5 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1,4 +1,4 @@ -import { useAtomMount } from "@effect/atom-react"; +import { useAtomMount, useAtomValue } from "@effect/atom-react"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; import { discardComposerDraft } from "../../lib/discardComposerDraft"; @@ -70,7 +70,8 @@ import { useUiStateStore } from "../../uiStateStore"; import { useThreadSelectionStore } from "../../threadSelectionStore"; import { useNowMinute } from "../../hooks/useNowMinute"; import { useNowSecond } from "../../hooks/useNowSecond"; -import { vcsEnvironment, vcsBranchRevalidation } from "../../state/vcs"; +import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; +import { vcsActionManager, vcsEnvironment, vcsBranchRevalidation } from "../../state/vcs"; import { useEnvironmentQuery } from "../../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampFormat"; @@ -713,7 +714,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : null; const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const status = resolveSidebarThreadStatus(thread); + const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); + const stackedAction = stackedActionThreads.get(`${thread.environmentId}:${thread.id}`); + const sourceControlBusy = isThreadStackedActionRunning(thread, stackedActionThreads); + const status = resolveSidebarThreadStatus(thread, { sourceControlBusy }); + const workingStartedAt = + resolveWorkingStartedAt(thread) ?? + (stackedAction === undefined ? null : new Date(stackedAction.startedAtMs).toISOString()); const lastVisitedDate = lastVisitedAt === undefined ? null : parseTimestampDate(lastVisitedAt); const wokeAtDate = props.wokeAt === null ? null : parseTimestampDate(props.wokeAt); const isWoke = @@ -1509,7 +1516,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {topStatus.label} {status === "preparing" || status === "working" ? ( - + ) : status === "waiting" ? ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e6..eb37c30ceaba 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,8 +134,9 @@ open. Threads that are working or waiting on work they will resume from move into a collapsed **Working** section below the active list. -That includes subagents, watched pull requests, and background commands or monitors the agent -says it is waiting on. A dev server or other process an agent leaves running does not keep a thread +That includes subagents, watched pull requests, background commands or monitors the agent +says it is waiting on, and a thread whose Git actions are generating a commit or pull request or +pushing the branch. A dev server or other process an agent leaves running does not keep a thread there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or answer. The Working section lists the thread you last sent work to first. Pinned threads stay in the pinned section. diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index d4a1355fb4ff..92a6f4f16093 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -38,13 +38,16 @@ import { createVcsActionManager, createVcsActionTransportId, EMPTY_VCS_ACTION_STATE, + isThreadStackedActionRunning, normalizeVcsActionProgressEvent, parseVcsActionTargetKey, + vcsStackedActionThreadKey, VcsActionMissingTerminalEventError, VcsActionRemoteFailureError, VcsActionTargetKeyParseError, } from "./vcsAction.ts"; import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; +import * as Deferred from "effect/Deferred"; const actionId = "action-123"; const action = "commit_push" as const; @@ -535,6 +538,7 @@ describe("vcsActionState", () => { expect(AsyncResult.isSuccess(successfulResult)).toBe(true); expect(registry.get(manager.stateAtom(targetKey))).toEqual(EMPTY_VCS_ACTION_STATE); + expect(registry.get(manager.stackedActionThreadsAtom).size).toBe(0); expect(registry.get(state).revision).toBe(1); expect(removed).toEqual([`${environmentId}:*`]); expect(rpcInputs).toEqual([ @@ -545,6 +549,7 @@ describe("vcsActionState", () => { manager.runStackedAction(targetKey).run(registry, { actionId: failedActionId, action, + threadId, }), ); @@ -555,6 +560,7 @@ describe("vcsActionState", () => { actionId: failedActionId, error: "Source control action 'commit_push' failed during push.", }); + expect(registry.get(manager.stackedActionThreadsAtom).size).toBe(0); expect(registry.get(state).revision).toBe(2); expect(removed).toEqual([`${environmentId}:*`, `${environmentId}:*`]); @@ -575,6 +581,101 @@ describe("vcsActionState", () => { }), ), ); + + it.effect("keeps the thread marked for the Working section while a stacked action runs", () => + Effect.scoped( + Effect.gen(function* () { + const connectionState: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }; + const targetKey = { environmentId, cwd }; + const runningActionId = "working-shelf-running"; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const client = { + [WS_METHODS.gitRunStackedAction]: (input: GitRunStackedActionInput) => + Stream.unwrap( + Effect.gen(function* () { + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + return Stream.make( + progress({ + kind: "action_finished", + actionId: input.actionId, + cwd, + action, + result, + }), + ); + }), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target, + state: yield* SubscriptionRef.make(connectionState), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + _environmentId, + effect, + ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["runStream"] = ( + _environmentId, + stream, + ) => Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + runStream, + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed( + Persistence.EnvironmentCacheStore, + cacheStore(() => undefined), + ), + ), + ); + const manager = createVcsActionManager(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const threadId = ThreadId.make("thread-working-shelf"); + const running = manager.runStackedAction(targetKey).run(registry, { + actionId: runningActionId, + action, + threadId, + }); + + yield* Deferred.await(entered); + expect(registry.get(manager.stateAtom(targetKey))).toMatchObject({ + isRunning: true, + threadId, + actionId: runningActionId, + }); + const busy = registry.get(manager.stackedActionThreadsAtom); + expect(busy.get(vcsStackedActionThreadKey(environmentId, threadId))).toMatchObject({ + actionId: runningActionId, + threadId, + }); + expect(isThreadStackedActionRunning({ environmentId, id: threadId }, busy)).toBe(true); + + yield* Deferred.succeed(release, undefined); + const finished = yield* Effect.promise(() => running); + expect(AsyncResult.isSuccess(finished)).toBe(true); + expect(registry.get(manager.stackedActionThreadsAtom).size).toBe(0); + }), + ), + ); }); vi.mock("./session.ts", () => ({ diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 5382e6116447..ec0e2db8218b 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -48,6 +48,7 @@ export interface VcsActionState { readonly operation: VcsActionOperation | null; readonly actionId: string | null; readonly action: GitStackedAction | null; + readonly threadId: ThreadId | null; readonly currentLabel: string | null; readonly currentPhaseLabel: string | null; readonly hookName: string | null; @@ -57,6 +58,14 @@ export interface VcsActionState { readonly error: string | null; } +/** A thread whose commit / push / PR stacked action is still running on this client. */ +export interface VcsStackedActionThreadPresence { + readonly environmentId: EnvironmentIdType; + readonly threadId: ThreadId; + readonly actionId: string; + readonly startedAtMs: number; +} + export interface VcsActionTarget { readonly environmentId: EnvironmentIdType | null; readonly cwd: string | null; @@ -71,6 +80,7 @@ export interface BeginVcsActionInput { readonly operation: VcsActionOperation; readonly label: string; readonly actionId?: string; + readonly threadId?: ThreadId; } export interface RunVcsStackedActionInput { @@ -153,6 +163,7 @@ export const EMPTY_VCS_ACTION_STATE = Object.freeze({ operation: null, actionId: null, action: null, + threadId: null, currentLabel: null, currentPhaseLabel: null, hookName: null, @@ -180,6 +191,52 @@ const EMPTY_VCS_ACTION_ATOM = Atom.make(EMPTY_VCS_ACTION_STATE).pipe( Atom.withLabel("vcs-action:null"), ); +const EMPTY_STACKED_ACTION_THREADS = Object.freeze( + new Map(), +); + +const vcsStackedActionThreadsAtom = Atom.make>( + EMPTY_STACKED_ACTION_THREADS, +).pipe(Atom.keepAlive, Atom.withLabel("vcs-stacked-action-threads")); + +export function vcsStackedActionThreadKey( + environmentId: EnvironmentIdType, + threadId: ThreadId, +): string { + return `${environmentId}:${threadId}`; +} + +export function isThreadStackedActionRunning( + thread: { readonly environmentId: EnvironmentIdType; readonly id: ThreadId }, + running: ReadonlyMap | ReadonlySet, +): boolean { + return running.has(vcsStackedActionThreadKey(thread.environmentId, thread.id)); +} + +function markStackedActionThread( + registry: AtomRegistry.AtomRegistry, + presence: VcsStackedActionThreadPresence, +): void { + const key = vcsStackedActionThreadKey(presence.environmentId, presence.threadId); + const current = registry.get(vcsStackedActionThreadsAtom); + if (current.get(key)?.actionId === presence.actionId) return; + const next = new Map(current); + next.set(key, presence); + registry.set(vcsStackedActionThreadsAtom, next); +} + +function clearStackedActionThread( + registry: AtomRegistry.AtomRegistry, + presence: Pick, +): void { + const key = vcsStackedActionThreadKey(presence.environmentId, presence.threadId); + const current = registry.get(vcsStackedActionThreadsAtom); + if (current.get(key)?.actionId !== presence.actionId) return; + const next = new Map(current); + next.delete(key); + registry.set(vcsStackedActionThreadsAtom, next.size === 0 ? EMPTY_STACKED_ACTION_THREADS : next); +} + function getVcsActionTargetKey(target: VcsActionTarget): string | null { if (target.environmentId === null || target.cwd === null) { return null; @@ -216,6 +273,7 @@ export function beginVcsActionState( isRunning: true, operation: input.operation, actionId, + threadId: input.threadId ?? null, currentLabel: input.label, currentPhaseLabel: input.label, phaseStartedAtMs: startedAt, @@ -462,8 +520,17 @@ export function createVcsActionManager( operation: "run_change_request", label: "Running source control action", actionId: input.actionId, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), }), ); + if (input.threadId !== undefined) { + markStackedActionThread(registry, { + environmentId: target.environmentId, + threadId: input.threadId, + actionId: input.actionId, + startedAtMs: nowMs(), + }); + } const rpcInput: GitRunStackedActionInput = { actionId: transportActionId, @@ -511,6 +578,16 @@ export function createVcsActionManager( createCommandPermissions(runtime, method).authorize(registry, id, payload), }), Effect.ensuring(invalidateCachedVcsRefs(registry, target)), + Effect.ensuring( + Effect.sync(() => { + if (input.threadId === undefined) return; + clearStackedActionThread(registry, { + environmentId: target.environmentId, + threadId: input.threadId, + actionId: input.actionId, + }); + }), + ), Effect.tap(() => clearOwnedState), Effect.tapError((error) => Effect.sync(() => { @@ -546,6 +623,7 @@ export function createVcsActionManager( return { stateAtom: getVcsActionStateAtom, + stackedActionThreadsAtom: vcsStackedActionThreadsAtom, runStackedAction: (target: VcsActionTarget) => getRunStackedActionCommand(target), track: async ( registry: AtomRegistry.AtomRegistry, From d20c2a82316a3da86fbacdd5dec0542c52a39546 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 16:28:07 -0800 Subject: [PATCH 02/11] Add working shelf evidence file --- WORKING_SHELF_EVIDENCE.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 WORKING_SHELF_EVIDENCE.md diff --git a/WORKING_SHELF_EVIDENCE.md b/WORKING_SHELF_EVIDENCE.md new file mode 100644 index 000000000000..990622818270 --- /dev/null +++ b/WORKING_SHELF_EVIDENCE.md @@ -0,0 +1 @@ +# working-shelf-evidence From 07a55539fc0eb75e4c59e7c9412ff18da8230edc Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 16:28:20 -0800 Subject: [PATCH 03/11] chore: remove temporary Working shelf evidence file --- WORKING_SHELF_EVIDENCE.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 WORKING_SHELF_EVIDENCE.md diff --git a/WORKING_SHELF_EVIDENCE.md b/WORKING_SHELF_EVIDENCE.md deleted file mode 100644 index 990622818270..000000000000 --- a/WORKING_SHELF_EVIDENCE.md +++ /dev/null @@ -1 +0,0 @@ -# working-shelf-evidence From da5e9fbb6dd08c8df74ed83678edf2061327e658 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sat, 7 Nov 2026 16:37:53 -0800 Subject: [PATCH 04/11] feat: show Committing, Pushing, or Submitting for Git actions Prefer agent and attention statuses; use the phase label only when the row would otherwise be ready. --- .../features/threads/thread-list-v2-items.tsx | 30 ++++++- .../features/threads/thread-row-emphasis.ts | 9 +- .../src/features/threads/threadListV2.test.ts | 23 ++++- .../src/features/threads/threadListV2.ts | 14 ++- apps/web/src/components/Sidebar.logic.test.ts | 15 +++- apps/web/src/components/Sidebar.logic.ts | 33 +++++-- .../components/sidebar/SidebarThreadRow.tsx | 28 ++++-- .../src/state/vcsAction.test.ts | 88 +++++++++++++++++-- .../client-runtime/src/state/vcsAction.ts | 42 ++++++++- 9 files changed, 241 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index b161ebf4054f..28d0748327bc 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -25,7 +25,7 @@ import { formatWorkingDurationLabel, resolveThreadWorkingStartedAt, } from "@t3tools/client-runtime/state/models"; -import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; +import { resolveSourceControlThreadStatus } from "@t3tools/client-runtime/state/vcs"; import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; @@ -118,6 +118,24 @@ const STATUS_LABEL_BY_STATUS: Partial> = className: "text-adaptive-sky-600-400", iconTintClassName: "accent-adaptive-sky-600-400", }, + committing: { + label: "Committing", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", + }, + pushing: { + label: "Pushing", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", + }, + submitting: { + label: "Submitting", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", + }, waiting: { label: null, icon: "circle.dashed.inset.filled", @@ -759,9 +777,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); const stackedAction = stackedActionThreads.get(`${thread.environmentId}:${thread.id}`); - const sourceControlBusy = isThreadStackedActionRunning(thread, stackedActionThreads); - const status = resolveThreadListV2Status(thread, { sourceControlBusy }); + const sourceControlStatus = resolveSourceControlThreadStatus(stackedAction); + const status = resolveThreadListV2Status(thread, { sourceControlStatus }); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); + const isSourceControlStatus = + status === "committing" || status === "pushing" || status === "submitting"; const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; const statusLabel = @@ -1245,11 +1265,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {statusLabel.label} - {status === "working" ? ( + {status === "working" || isSourceControlStatus ? ( { expect(resolveThreadListV2Status({ ...guest, hasPendingApprovals: true })).toBe("approval"); }); - it("treats an in-flight commit, push, or PR as working ahead of ready", () => { + it("labels in-flight commit, push, or PR only when nothing else owns the row", () => { const thread = presentThreadShell( environmentId, decodeThreadShell(makeRawThreadShell({ status: "completed" })), ); - expect(resolveThreadListV2Status(thread, { sourceControlBusy: true })).toBe("working"); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "committing" })).toBe( + "committing", + ); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "pushing" })).toBe("pushing"); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "submitting" })).toBe( + "submitting", + ); expect( resolveThreadListV2Status( { ...thread, hasPendingUserInput: true }, - { sourceControlBusy: true }, + { sourceControlStatus: "committing" }, ), ).toBe("input"); + const running = presentThreadShell( + environmentId, + decodeThreadShell( + makeRawThreadShell({ + status: "running", + activeRunId: RunId.make("run-source-control"), + latestRunId: RunId.make("run-source-control"), + }), + ), + ); + expect(resolveThreadListV2Status(running, { sourceControlStatus: "pushing" })).toBe("working"); }); it.each([ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 4243b5904454..3f6072612a90 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -68,6 +68,9 @@ export type ThreadListV2Status = | "input" | "request" | "working" + | "committing" + | "pushing" + | "submitting" | "waiting" | "failed" | "limited" @@ -267,7 +270,9 @@ export function resolveThreadListV2Status( | "runtime" | "blockedOnThreadWait" >, - options?: { readonly sourceControlBusy?: boolean }, + options?: { + readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | null; + }, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; @@ -279,9 +284,6 @@ export function resolveThreadListV2Status( if ((thread.pendingGuestMessageCount ?? 0) > 0) { return "request"; } - if (options?.sourceControlBusy === true) { - return "working"; - } if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } @@ -297,6 +299,10 @@ export function resolveThreadListV2Status( if (thread.runtime?.status === "failed") { return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed"; } + // Commit / push / PR only when nothing more urgent owns the row. + if (options?.sourceControlStatus != null) { + return options.sourceControlStatus; + } return "ready"; } diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d15afa51db2b..c2f9dd9dc4d0 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -311,14 +311,23 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("waiting"); }); - it("treats an in-flight commit, push, or PR as working ahead of ready", () => { - expect(resolveSidebarThreadStatus(idle, { sourceControlBusy: true })).toBe("working"); + it("labels in-flight commit, push, or PR only when nothing else owns the row", () => { + expect(resolveSidebarThreadStatus(idle, { sourceControlStatus: "committing" })).toBe( + "committing", + ); + expect(resolveSidebarThreadStatus(idle, { sourceControlStatus: "pushing" })).toBe("pushing"); + expect(resolveSidebarThreadStatus(idle, { sourceControlStatus: "submitting" })).toBe( + "submitting", + ); expect( resolveSidebarThreadStatus( { ...idle, hasPendingUserInput: true }, - { sourceControlBusy: true }, + { sourceControlStatus: "committing" }, ), ).toBe("input"); + expect( + resolveSidebarThreadStatus({ ...idle, runtime }, { sourceControlStatus: "pushing" }), + ).toBe("working"); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3f3a719ccdb0..dcd50135b234 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -819,6 +819,9 @@ export type SidebarThreadStatus = | "request" | "preparing" | "working" + | "committing" + | "pushing" + | "submitting" | "waiting" | "failed" | "limited" @@ -839,7 +842,14 @@ export function shouldRecedeSidebarThread(input: { ) { return false; } - if (input.status === "preparing" || input.status === "working" || input.status === "waiting") { + if ( + input.status === "preparing" || + input.status === "working" || + input.status === "committing" || + input.status === "pushing" || + input.status === "submitting" || + input.status === "waiting" + ) { return true; } if (input.status === "ready" || input.status === "approval") { @@ -860,7 +870,9 @@ type SidebarThreadStatusInput = Pick< export function resolveSidebarThreadStatus( thread: SidebarThreadStatusInput, - options?: { readonly sourceControlBusy?: boolean }, + options?: { + readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | null; + }, ): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; @@ -872,9 +884,6 @@ export function resolveSidebarThreadStatus( if ((thread.pendingGuestMessageCount ?? 0) > 0) { return "request"; } - if (options?.sourceControlBusy === true) { - return "working"; - } if (thread.runtime?.status === "running" && thread.blockedOnThreadWait === true) { return "waiting"; } @@ -893,6 +902,10 @@ export function resolveSidebarThreadStatus( if (thread.runtime?.status === "failed") { return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed"; } + // Commit / push / PR only when nothing more urgent owns the row. + if (options?.sourceControlStatus != null) { + return options.sourceControlStatus; + } return "ready"; } @@ -918,7 +931,15 @@ export function resolveSidebarFolderDot( (thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread), ); if (hasUnread) return "unread"; - if (statuses.has("working") || statuses.has("preparing")) return "working"; + if ( + statuses.has("working") || + statuses.has("preparing") || + statuses.has("committing") || + statuses.has("pushing") || + statuses.has("submitting") + ) { + return "working"; + } return null; } diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index ed1a32b8ffd5..ec4e37baa0b9 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -70,7 +70,7 @@ import { useUiStateStore } from "../../uiStateStore"; import { useThreadSelectionStore } from "../../threadSelectionStore"; import { useNowMinute } from "../../hooks/useNowMinute"; import { useNowSecond } from "../../hooks/useNowSecond"; -import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; +import { resolveSourceControlThreadStatus } from "@t3tools/client-runtime/state/vcs"; import { vcsActionManager, vcsEnvironment, vcsBranchRevalidation } from "../../state/vcs"; import { useEnvironmentQuery } from "../../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; @@ -716,8 +716,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); const stackedAction = stackedActionThreads.get(`${thread.environmentId}:${thread.id}`); - const sourceControlBusy = isThreadStackedActionRunning(thread, stackedActionThreads); - const status = resolveSidebarThreadStatus(thread, { sourceControlBusy }); + const sourceControlStatus = resolveSourceControlThreadStatus(stackedAction); + const status = resolveSidebarThreadStatus(thread, { sourceControlStatus }); const workingStartedAt = resolveWorkingStartedAt(thread) ?? (stackedAction === undefined ? null : new Date(stackedAction.startedAtMs).toISOString()); @@ -734,15 +734,23 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, isSelected, }); + const isSourceControlStatus = + status === "committing" || status === "pushing" || status === "submitting"; const topStatus = - status === "preparing" || status === "working" + status === "preparing" || status === "working" || isSourceControlStatus ? { label: status === "preparing" ? "Setting up" - : thread.goal?.status === "active" - ? "Goal" - : "Working", + : status === "committing" + ? "Committing" + : status === "pushing" + ? "Pushing" + : status === "submitting" + ? "Submitting" + : thread.goal?.status === "active" + ? "Goal" + : "Working", icon: "working" as const, className: "text-info-foreground", } @@ -976,7 +984,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", shouldRecede && - (status === "preparing" || status === "working") && + (status === "preparing" || status === "working" || isSourceControlStatus) && "opacity-70 transition-opacity hover:opacity-100 focus-within:opacity-100 motion-reduce:transition-none", isFileDragOver && "ring-1 ring-inset ring-primary/70", isFileDragOver && !props.isActive && !isSelected && "bg-sidebar-row-hover", @@ -1514,7 +1522,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP BackgroundThreadAnnouncer and the open thread's timeline speak status changes instead. */} {topStatus.label} - {status === "preparing" || status === "working" ? ( + {status === "preparing" || + status === "working" || + isSourceControlStatus ? ( diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 92a6f4f16093..a9c75996e252 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -41,6 +41,7 @@ import { isThreadStackedActionRunning, normalizeVcsActionProgressEvent, parseVcsActionTargetKey, + resolveSourceControlThreadStatus, vcsStackedActionThreadKey, VcsActionMissingTerminalEventError, VcsActionRemoteFailureError, @@ -596,6 +597,7 @@ describe("vcsActionState", () => { const targetKey = { environmentId, cwd }; const runningActionId = "working-shelf-running"; const entered = yield* Deferred.make(); + const afterPhase = yield* Deferred.make(); const release = yield* Deferred.make(); const client = { [WS_METHODS.gitRunStackedAction]: (input: GitRunStackedActionInput) => @@ -603,14 +605,30 @@ describe("vcsActionState", () => { Effect.gen(function* () { yield* Deferred.succeed(entered, undefined); yield* Deferred.await(release); - return Stream.make( - progress({ - kind: "action_finished", - actionId: input.actionId, - cwd, - action, - result, - }), + return Stream.concat( + Stream.make( + progress({ + kind: "phase_started", + actionId: input.actionId, + cwd, + action, + phase: "push", + label: "Pushing", + }), + ), + Stream.fromEffect(Deferred.await(afterPhase)).pipe( + Stream.flatMap(() => + Stream.make( + progress({ + kind: "action_finished", + actionId: input.actionId, + cwd, + action, + result, + }), + ), + ), + ), ); }), ), @@ -650,10 +668,19 @@ describe("vcsActionState", () => { Effect.sync(() => registry.dispose()), ); const threadId = ThreadId.make("thread-working-shelf"); + let resolvePhaseSeen!: () => void; + const phaseSeen = new Promise((resolve) => { + resolvePhaseSeen = resolve; + }); const running = manager.runStackedAction(targetKey).run(registry, { actionId: runningActionId, action, threadId, + onProgress: (event) => { + if (event.kind === "phase_started" && event.phase === "push") { + resolvePhaseSeen(); + } + }, }); yield* Deferred.await(entered); @@ -663,13 +690,25 @@ describe("vcsActionState", () => { actionId: runningActionId, }); const busy = registry.get(manager.stackedActionThreadsAtom); - expect(busy.get(vcsStackedActionThreadKey(environmentId, threadId))).toMatchObject({ + const presence = busy.get(vcsStackedActionThreadKey(environmentId, threadId)); + expect(presence).toMatchObject({ actionId: runningActionId, threadId, + action, + phase: null, }); expect(isThreadStackedActionRunning({ environmentId, id: threadId }, busy)).toBe(true); + expect(resolveSourceControlThreadStatus(presence)).toBe("committing"); yield* Deferred.succeed(release, undefined); + yield* Effect.promise(() => phaseSeen); + const mid = registry + .get(manager.stackedActionThreadsAtom) + .get(vcsStackedActionThreadKey(environmentId, threadId)); + expect(mid?.phase).toBe("push"); + expect(resolveSourceControlThreadStatus(mid)).toBe("pushing"); + + yield* Deferred.succeed(afterPhase, undefined); const finished = yield* Effect.promise(() => running); expect(AsyncResult.isSuccess(finished)).toBe(true); expect(registry.get(manager.stackedActionThreadsAtom).size).toBe(0); @@ -678,6 +717,37 @@ describe("vcsActionState", () => { ); }); +describe("resolveSourceControlThreadStatus", () => { + const base = { + environmentId, + threadId: ThreadId.make("thread-status"), + actionId: "action-status", + startedAtMs: 1, + }; + + it("maps phase first, then stacked action kind", () => { + expect( + resolveSourceControlThreadStatus({ ...base, action: "commit_push_pr", phase: "push" }), + ).toBe("pushing"); + expect( + resolveSourceControlThreadStatus({ ...base, action: "commit_push_pr", phase: "pr" }), + ).toBe("submitting"); + expect( + resolveSourceControlThreadStatus({ ...base, action: "commit_push_pr", phase: "commit" }), + ).toBe("committing"); + expect( + resolveSourceControlThreadStatus({ ...base, action: "commit_push_pr", phase: null }), + ).toBe("committing"); + expect(resolveSourceControlThreadStatus({ ...base, action: "push", phase: null })).toBe( + "pushing", + ); + expect(resolveSourceControlThreadStatus({ ...base, action: "create_pr", phase: null })).toBe( + "submitting", + ); + expect(resolveSourceControlThreadStatus(null)).toBeNull(); + }); +}); + vi.mock("./session.ts", () => ({ createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), })); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index ec0e2db8218b..7ef7b5dba5fd 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -63,9 +63,26 @@ export interface VcsStackedActionThreadPresence { readonly environmentId: EnvironmentIdType; readonly threadId: ThreadId; readonly actionId: string; + readonly action: GitStackedAction; + readonly phase: GitActionProgressPhase | null; readonly startedAtMs: number; } +/** Sidebar / home-list status while a stacked Git action owns the thread. */ +export type SourceControlThreadStatus = "committing" | "pushing" | "submitting"; + +export function resolveSourceControlThreadStatus( + presence: VcsStackedActionThreadPresence | null | undefined, +): SourceControlThreadStatus | null { + if (presence === null || presence === undefined) return null; + if (presence.phase === "push") return "pushing"; + if (presence.phase === "pr") return "submitting"; + if (presence.phase === "commit" || presence.phase === "branch") return "committing"; + if (presence.action === "push") return "pushing"; + if (presence.action === "create_pr") return "submitting"; + return "committing"; +} + export interface VcsActionTarget { readonly environmentId: EnvironmentIdType | null; readonly cwd: string | null; @@ -219,7 +236,15 @@ function markStackedActionThread( ): void { const key = vcsStackedActionThreadKey(presence.environmentId, presence.threadId); const current = registry.get(vcsStackedActionThreadsAtom); - if (current.get(key)?.actionId === presence.actionId) return; + const existing = current.get(key); + if ( + existing?.actionId === presence.actionId && + existing.action === presence.action && + existing.phase === presence.phase && + existing.startedAtMs === presence.startedAtMs + ) { + return; + } const next = new Map(current); next.set(key, presence); registry.set(vcsStackedActionThreadsAtom, next); @@ -523,12 +548,15 @@ export function createVcsActionManager( ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), }), ); + const startedAtMs = nowMs(); if (input.threadId !== undefined) { markStackedActionThread(registry, { environmentId: target.environmentId, threadId: input.threadId, actionId: input.actionId, - startedAtMs: nowMs(), + action: input.action, + phase: null, + startedAtMs, }); } @@ -565,6 +593,16 @@ export function createVcsActionManager( return; } registry.set(stateAtom, applyVcsActionProgressEvent(current, event)); + if (input.threadId !== undefined && event.kind === "phase_started") { + markStackedActionThread(registry, { + environmentId: target.environmentId, + threadId: input.threadId, + actionId: input.actionId, + action: input.action, + phase: event.phase, + startedAtMs, + }); + } if (input.onProgress !== undefined) { try { input.onProgress(event); From 0034e6dc3bce35d9c4ce94a72b2e6e9a44fa7d55 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sun, 8 Nov 2026 07:37:52 -0800 Subject: [PATCH 05/11] feat: keep merging linked PRs in the Working section Mark threads linked to a PR as busy while a merge runs so they stay under Working with a Merging label, matching commit/push/submit. Co-authored-by: Cursor --- .../PullRequestMergeControls.tsx | 51 +++++++++++++- .../features/threads/thread-list-v2-items.tsx | 11 ++- .../features/threads/thread-row-emphasis.ts | 1 + .../src/features/threads/threadListV2.test.ts | 3 +- .../src/features/threads/threadListV2.ts | 3 +- apps/web/src/components/Sidebar.logic.test.ts | 3 +- apps/web/src/components/Sidebar.logic.ts | 7 +- .../components/chat/ThreadDetailsPrRow.tsx | 5 +- .../components/chat/ThreadDetailsPrRows.tsx | 12 +++- .../pullRequest/PullRequestDetailPanel.tsx | 17 +++++ .../pullRequest/usePullRequestActions.ts | 70 ++++++++++++++++++- .../components/sidebar/SidebarThreadRow.tsx | 13 ++-- docs/user/thread-sidebar.md | 6 +- .../src/state/vcsAction.test.ts | 35 ++++++++++ .../client-runtime/src/state/vcsAction.ts | 50 +++++++++++-- 15 files changed, 261 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx index 2d0414b50365..3655b94c3280 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestMergeControls.tsx @@ -1,6 +1,11 @@ import * as Dialog from "../../components/ui/Dialog"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + clearSourceControlThreadBusy, + markSourceControlThreadBusy, + threadIdsLinkedToPullRequest, +} from "@t3tools/client-runtime/state/vcs"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import type { PullRequestDetail, PullRequestMergeMethod } from "@t3tools/contracts"; import { AsyncResult } from "effect/reactivity"; @@ -9,8 +14,10 @@ import { View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { Button } from "../../components/ui/Button"; import { SplitButton } from "../../components/ui/SplitButton"; +import { appAtomRegistry } from "../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { composerPullRequests } from "../../state/pull-requests"; +import { environmentThreadShells } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; import { allowedMergeMethods, @@ -134,6 +141,36 @@ export function PullRequestMergeControls(props: { return; pending.current = true; setBusy(true); + const mergeThreadIds = + action === "merge" + ? threadIdsLinkedToPullRequest( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + target.environmentId, + target.input, + ) + : []; + const mergeActionId = + mergeThreadIds.length === 0 + ? null + : `merge:${JSON.stringify([ + target.environmentId, + target.input.host ?? null, + target.input.repository, + target.input.number, + ])}`; + const mergeStartedAtMs = Date.now(); + if (mergeActionId !== null) { + for (const threadId of mergeThreadIds) { + markSourceControlThreadBusy(appAtomRegistry, { + environmentId: target.environmentId, + threadId, + actionId: mergeActionId, + action: "merge", + phase: null, + startedAtMs: mergeStartedAtMs, + }); + } + } const response = await runAction({ ...target, input: { @@ -160,13 +197,23 @@ export function PullRequestMergeControls(props: { ? { mergeMethod: method } : {}), }, + }).finally(() => { + if (mergeActionId !== null) { + for (const threadId of mergeThreadIds) { + clearSourceControlThreadBusy(appAtomRegistry, { + environmentId: target.environmentId, + threadId, + actionId: mergeActionId, + }); + } + } + pending.current = false; + setBusy(false); }); // A stack can partly merge before the provider reports a failure. if (!AsyncResult.isSuccess(response)) refreshDetail(); refreshActivity(); stack.refresh(); - pending.current = false; - setBusy(false); if (AsyncResult.isFailure(response)) { const cause = squashAtomCommandFailure(response); Dialog.alert( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 28d0748327bc..c9fecc705b77 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -136,6 +136,12 @@ const STATUS_LABEL_BY_STATUS: Partial> = className: "text-adaptive-sky-600-400", iconTintClassName: "accent-adaptive-sky-600-400", }, + merging: { + label: "Merging", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", + }, waiting: { label: null, icon: "circle.dashed.inset.filled", @@ -781,7 +787,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const status = resolveThreadListV2Status(thread, { sourceControlStatus }); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); const isSourceControlStatus = - status === "committing" || status === "pushing" || status === "submitting"; + status === "committing" || + status === "pushing" || + status === "submitting" || + status === "merging"; const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; const statusLabel = diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.ts b/apps/mobile/src/features/threads/thread-row-emphasis.ts index e5416e565f27..d59108c7e0f7 100644 --- a/apps/mobile/src/features/threads/thread-row-emphasis.ts +++ b/apps/mobile/src/features/threads/thread-row-emphasis.ts @@ -10,6 +10,7 @@ export function shouldRecedeThreadRow(input: { input.status === "committing" || input.status === "pushing" || input.status === "submitting" || + input.status === "merging" || input.status === "waiting") ); } diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 1dbf0d986c08..a0de09389287 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -187,7 +187,7 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status({ ...guest, hasPendingApprovals: true })).toBe("approval"); }); - it("labels in-flight commit, push, or PR only when nothing else owns the row", () => { + it("labels in-flight commit, push, PR, or merge only when nothing else owns the row", () => { const thread = presentThreadShell( environmentId, decodeThreadShell(makeRawThreadShell({ status: "completed" })), @@ -199,6 +199,7 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status(thread, { sourceControlStatus: "submitting" })).toBe( "submitting", ); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "merging" })).toBe("merging"); expect( resolveThreadListV2Status( { ...thread, hasPendingUserInput: true }, diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3f6072612a90..bbb4abf6d476 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -71,6 +71,7 @@ export type ThreadListV2Status = | "committing" | "pushing" | "submitting" + | "merging" | "waiting" | "failed" | "limited" @@ -271,7 +272,7 @@ export function resolveThreadListV2Status( | "blockedOnThreadWait" >, options?: { - readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | null; + readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | "merging" | null; }, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index c2f9dd9dc4d0..933237f40c46 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -311,7 +311,7 @@ describe("resolveSidebarThreadStatus", () => { ).toBe("waiting"); }); - it("labels in-flight commit, push, or PR only when nothing else owns the row", () => { + it("labels in-flight commit, push, PR, or merge only when nothing else owns the row", () => { expect(resolveSidebarThreadStatus(idle, { sourceControlStatus: "committing" })).toBe( "committing", ); @@ -319,6 +319,7 @@ describe("resolveSidebarThreadStatus", () => { expect(resolveSidebarThreadStatus(idle, { sourceControlStatus: "submitting" })).toBe( "submitting", ); + expect(resolveSidebarThreadStatus(idle, { sourceControlStatus: "merging" })).toBe("merging"); expect( resolveSidebarThreadStatus( { ...idle, hasPendingUserInput: true }, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index dcd50135b234..669df1bcbc96 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -822,6 +822,7 @@ export type SidebarThreadStatus = | "committing" | "pushing" | "submitting" + | "merging" | "waiting" | "failed" | "limited" @@ -848,6 +849,7 @@ export function shouldRecedeSidebarThread(input: { input.status === "committing" || input.status === "pushing" || input.status === "submitting" || + input.status === "merging" || input.status === "waiting" ) { return true; @@ -871,7 +873,7 @@ type SidebarThreadStatusInput = Pick< export function resolveSidebarThreadStatus( thread: SidebarThreadStatusInput, options?: { - readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | null; + readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | "merging" | null; }, ): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { @@ -936,7 +938,8 @@ export function resolveSidebarFolderDot( statuses.has("preparing") || statuses.has("committing") || statuses.has("pushing") || - statuses.has("submitting") + statuses.has("submitting") || + statuses.has("merging") ) { return "working"; } diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 23d705a8dae7..9a58827a796f 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -8,7 +8,7 @@ import { ThreadDetailsControl } from "./ThreadDetailsControl"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import { selectEnvironmentQueryData } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import type { EnvironmentId, ProjectId, PullRequestRef } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, PullRequestRef, ThreadId } from "@t3tools/contracts"; import { ArrowUpRightIcon, EyeIcon, @@ -83,6 +83,7 @@ const isSettledWithoutChecks = (result: AsyncResult.AsyncResult | null; @@ -162,6 +164,7 @@ export function ThreadDetailsPrRow({ environmentId, reference, terminology, + ...(threadId === undefined ? {} : { threadIds: [threadId] }), onSuccess: () => { checksQuery.refresh(); onActed?.(); diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index 2da6019787f5..bd55f754881f 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -24,12 +24,14 @@ import { ThreadDetailsPrRow } from "./ThreadDetailsPrRow"; function ThreadDetailsPrLinkRow({ environmentId, + threadId, link, onOpen, onActed, onStopWatching, }: { environmentId: EnvironmentId; + threadId: ScopedThreadRef["threadId"]; link: ThreadPullRequestLink; onOpen: (event: ReactMouseEvent) => void; onActed?: (() => void) | undefined; @@ -49,6 +51,7 @@ function ThreadDetailsPrLinkRow({ return ( ; + const currentRow = ( + + ); const rest = currentLink === null ? [] @@ -109,6 +118,7 @@ export function ThreadDetailsPrRows({ onOpenLink(event, link.url)} onActed={row.onActed} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 9d0f6193f7e3..e141663086fc 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -50,6 +50,8 @@ import { isCommandPaletteOpen } from "~/commandPaletteBus"; import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { + beginPullRequestMergeBusy, + endPullRequestMergeBusy, usePullRequestDefaultMergeMethodResolver, writeHandoffToComposer, } from "./usePullRequestActions"; @@ -662,6 +664,19 @@ export function PullRequestDetailPanel({ updateMethod?: PullRequestUpdateMethod, ) => { onActed?.(action, "sent"); + const mergeBusy = + action === "merge" + ? beginPullRequestMergeBusy( + environmentId, + reference, + JSON.stringify([ + environmentId, + reference.host ?? null, + reference.repository, + reference.number, + ]), + ) + : null; const result = await runAction({ environmentId, input: { @@ -670,6 +685,8 @@ export function PullRequestDetailPanel({ ...(method ? { mergeMethod: method } : {}), ...(updateMethod ? { updateMethod } : {}), }, + }).finally(() => { + endPullRequestMergeBusy(environmentId, mergeBusy); }); setPendingAction(null); if (result._tag === "Failure") { diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index b4814dfbb5a4..a6d624db1a6c 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -4,6 +4,11 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { + clearSourceControlThreadBusy, + markSourceControlThreadBusy, + threadIdsLinkedToPullRequest, +} from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, ProjectId, @@ -13,6 +18,7 @@ import type { PullRequestRef, PullRequestSummary, ScopedThreadRef, + ThreadId, } from "@t3tools/contracts"; import { useRef, useState } from "react"; import { @@ -30,12 +36,14 @@ import { import { buildPhysicalToLogicalProjectKeyMap } from "~/sidebarProjectGrouping"; import { useProjects } from "~/state/entities"; import { usePrimaryEnvironmentId } from "~/state/environments"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { usePreparePullRequestThreadAction } from "~/state/sourceControlActions"; import type { ReviewCommentContext } from "~/reviewCommentContext"; import { pullRequestEnvironment } from "~/state/pullRequests"; +import { environmentThreadShells } from "~/state/threads"; import { toastManager } from "../ui/toast"; import { offHeadPullRequestCheckoutToast } from "./pullRequestCheckoutToast"; @@ -124,12 +132,57 @@ const actionFailureHints = (term: string): Record => "The host refused it. Check that you have Actions write access and that these workflow runs are still awaiting approval.", }); +export function beginPullRequestMergeBusy( + environmentId: EnvironmentId, + reference: Pick, + actionKey: string, + threadIds?: ReadonlyArray, +): { readonly actionId: string; readonly threadIds: ReadonlyArray } | null { + const linked = + threadIds !== undefined && threadIds.length > 0 + ? threadIds + : threadIdsLinkedToPullRequest( + appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + environmentId, + reference, + ); + if (linked.length === 0) return null; + const actionId = `merge:${actionKey}`; + const startedAtMs = Date.now(); + for (const threadId of linked) { + markSourceControlThreadBusy(appAtomRegistry, { + environmentId, + threadId, + actionId, + action: "merge", + phase: null, + startedAtMs, + }); + } + return { actionId, threadIds: linked }; +} + +export function endPullRequestMergeBusy( + environmentId: EnvironmentId, + busy: { readonly actionId: string; readonly threadIds: ReadonlyArray } | null, +): void { + if (busy === null) return; + for (const threadId of busy.threadIds) { + clearSourceControlThreadBusy(appAtomRegistry, { + environmentId, + threadId, + actionId: busy.actionId, + }); + } +} + export function usePullRequestActionRunner({ environmentId, reference, terminology, onSuccess, resolveMergeMethod, + threadIds, }: { environmentId: EnvironmentId; reference: PullRequestRef | null; @@ -137,6 +190,8 @@ export function usePullRequestActionRunner({ onSuccess?: (action: PullRequestAction, reply: PullRequestSummary | undefined) => void; /** Resolve repository settings on click to avoid per-row reads. */ resolveMergeMethod?: (detail: PullRequestDetail) => PullRequestMergeMethod; + /** Prefer when the caller already knows which threads link this PR. */ + threadIds?: ReadonlyArray; }) { const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false, @@ -158,6 +213,10 @@ export function usePullRequestActionRunner({ if (actionKey === null || reference === null || pendingRef.current.has(actionKey)) return; pendingRef.current.add(actionKey); setPendingKeys(new Set(pendingRef.current)); + const mergeBusy = + action === "merge" + ? beginPullRequestMergeBusy(environmentId, reference, actionKey, threadIds) + : null; const result = await runAction({ environmentId, input: { @@ -165,9 +224,11 @@ export function usePullRequestActionRunner({ action, ...(method ? { mergeMethod: method } : resolveMergeMethod ? { resolveMergeMethod } : {}), }, + }).finally(() => { + endPullRequestMergeBusy(environmentId, mergeBusy); + pendingRef.current.delete(actionKey); + setPendingKeys(new Set(pendingRef.current)); }); - pendingRef.current.delete(actionKey); - setPendingKeys(new Set(pendingRef.current)); if (result._tag === "Failure") { toastManager.add({ type: "error", @@ -179,7 +240,10 @@ export function usePullRequestActionRunner({ }); return; } - toastManager.add({ type: "success", title: actionSuccessLabels(terminology.singular)[action] }); + toastManager.add({ + type: "success", + title: actionSuccessLabels(terminology.singular)[action], + }); onSuccess?.(action, result.value); }; diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index ec4e37baa0b9..4237a9eec8c1 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -735,7 +735,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isSelected, }); const isSourceControlStatus = - status === "committing" || status === "pushing" || status === "submitting"; + status === "committing" || + status === "pushing" || + status === "submitting" || + status === "merging"; const topStatus = status === "preparing" || status === "working" || isSourceControlStatus ? { @@ -748,9 +751,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ? "Pushing" : status === "submitting" ? "Submitting" - : thread.goal?.status === "active" - ? "Goal" - : "Working", + : status === "merging" + ? "Merging" + : thread.goal?.status === "active" + ? "Goal" + : "Working", icon: "working" as const, className: "text-info-foreground", } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index eb37c30ceaba..07f0e465ae48 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -135,9 +135,9 @@ open. Threads that are working or waiting on work they will resume from move into a collapsed **Working** section below the active list. That includes subagents, watched pull requests, background commands or monitors the agent -says it is waiting on, and a thread whose Git actions are generating a commit or pull request or -pushing the branch. A dev server or other process an agent leaves running does not keep a thread -there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or +says it is waiting on, and a thread whose Git actions are generating a commit or pull request, +pushing the branch, or merging a linked pull request. A dev server or other process an agent +leaves running does not keep a thread there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or answer. The Working section lists the thread you last sent work to first. Pinned threads stay in the pinned section. diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index a9c75996e252..dfa0e61c9608 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -42,6 +42,7 @@ import { normalizeVcsActionProgressEvent, parseVcsActionTargetKey, resolveSourceControlThreadStatus, + threadIdsLinkedToPullRequest, vcsStackedActionThreadKey, VcsActionMissingTerminalEventError, VcsActionRemoteFailureError, @@ -744,10 +745,44 @@ describe("resolveSourceControlThreadStatus", () => { expect(resolveSourceControlThreadStatus({ ...base, action: "create_pr", phase: null })).toBe( "submitting", ); + expect(resolveSourceControlThreadStatus({ ...base, action: "merge", phase: null })).toBe( + "merging", + ); expect(resolveSourceControlThreadStatus(null)).toBeNull(); }); }); +describe("threadIdsLinkedToPullRequest", () => { + it("returns threads whose linked PRs match host, repository, and number", () => { + const threadA = ThreadId.make("thread-a"); + const threadB = ThreadId.make("thread-b"); + const threadC = ThreadId.make("thread-c"); + expect( + threadIdsLinkedToPullRequest( + [ + { + environmentId, + id: threadA, + pullRequests: [{ repository: "owner/repo", number: 7, host: "github.com" }], + }, + { + environmentId, + id: threadB, + pullRequests: [{ repository: "owner/repo", number: 8 }], + }, + { + environmentId: EnvironmentId.make("other"), + id: threadC, + pullRequests: [{ repository: "owner/repo", number: 7, host: "github.com" }], + }, + ], + environmentId, + { repository: "owner/repo", number: 7, host: "github.com" }, + ), + ).toEqual([threadA]); + }); +}); + vi.mock("./session.ts", () => ({ createEnvironmentSessionAtoms: () => ({ sessionStateAtom: grantedSessions }), })); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 7ef7b5dba5fd..e96383247b9b 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -58,23 +58,27 @@ export interface VcsActionState { readonly error: string | null; } -/** A thread whose commit / push / PR stacked action is still running on this client. */ +/** Client-local busy work that should keep a thread in the Working section. */ +export type SourceControlBusyAction = GitStackedAction | "merge"; + +/** A thread whose commit / push / PR / merge action is still running on this client. */ export interface VcsStackedActionThreadPresence { readonly environmentId: EnvironmentIdType; readonly threadId: ThreadId; readonly actionId: string; - readonly action: GitStackedAction; + readonly action: SourceControlBusyAction; readonly phase: GitActionProgressPhase | null; readonly startedAtMs: number; } -/** Sidebar / home-list status while a stacked Git action owns the thread. */ -export type SourceControlThreadStatus = "committing" | "pushing" | "submitting"; +/** Sidebar / home-list status while a source-control action owns the thread. */ +export type SourceControlThreadStatus = "committing" | "pushing" | "submitting" | "merging"; export function resolveSourceControlThreadStatus( presence: VcsStackedActionThreadPresence | null | undefined, ): SourceControlThreadStatus | null { if (presence === null || presence === undefined) return null; + if (presence.action === "merge") return "merging"; if (presence.phase === "push") return "pushing"; if (presence.phase === "pr") return "submitting"; if (presence.phase === "commit" || presence.phase === "branch") return "committing"; @@ -230,7 +234,7 @@ export function isThreadStackedActionRunning( return running.has(vcsStackedActionThreadKey(thread.environmentId, thread.id)); } -function markStackedActionThread( +export function markSourceControlThreadBusy( registry: AtomRegistry.AtomRegistry, presence: VcsStackedActionThreadPresence, ): void { @@ -250,7 +254,7 @@ function markStackedActionThread( registry.set(vcsStackedActionThreadsAtom, next); } -function clearStackedActionThread( +export function clearSourceControlThreadBusy( registry: AtomRegistry.AtomRegistry, presence: Pick, ): void { @@ -262,6 +266,40 @@ function clearStackedActionThread( registry.set(vcsStackedActionThreadsAtom, next.size === 0 ? EMPTY_STACKED_ACTION_THREADS : next); } +export function threadIdsLinkedToPullRequest( + shells: Iterable<{ + readonly environmentId: EnvironmentIdType; + readonly id: ThreadId; + readonly pullRequests: ReadonlyArray<{ + readonly host?: string | undefined; + readonly repository: string; + readonly number: number; + }>; + }>, + environmentId: EnvironmentIdType, + reference: { + readonly host?: string | undefined; + readonly repository: string; + readonly number: number; + }, +): ThreadId[] { + const ids: ThreadId[] = []; + for (const shell of shells) { + if (shell.environmentId !== environmentId) continue; + const linked = shell.pullRequests.some( + (link) => + link.number === reference.number && + link.repository === reference.repository && + (reference.host === undefined || link.host === reference.host), + ); + if (linked) ids.push(shell.id); + } + return ids; +} + +const markStackedActionThread = markSourceControlThreadBusy; +const clearStackedActionThread = clearSourceControlThreadBusy; + function getVcsActionTargetKey(target: VcsActionTarget): string | null { if (target.environmentId === null || target.cwd === null) { return null; From 1ce919c15f3b78f95577cc9605967b6e5a234218 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sun, 8 Nov 2026 09:18:31 -0800 Subject: [PATCH 06/11] fix: keep approval threads out of Working during Git actions Gate source-control busy shelf placement on the same attention checks as agent work, feed that predicate into inbox return tracking so Git-only finishes resurface at the top, and pass stacked-action status into collapsed folder dots. Co-authored-by: Cursor --- .../src/features/threads/useThreadListV2.ts | 20 ++++++---- apps/web/src/components/Sidebar.logic.test.ts | 5 +++ apps/web/src/components/Sidebar.logic.ts | 20 ++++++++-- apps/web/src/components/Sidebar.tsx | 19 +++++++-- .../sidebar/SidebarProjectFolderHeader.tsx | 10 +++++ .../src/state/threadInbox.test.ts | 40 +++++++++++++++++++ .../client-runtime/src/state/threadInbox.ts | 29 ++++++++++++-- 7 files changed, 123 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 26e29ff13278..0c1b15107d80 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,4 +1,5 @@ import type { OlderSettledScope } from "@t3tools/client-runtime/state/threads"; +import { isThreadWorkingOrSourceControlBusy } from "@t3tools/client-runtime/state/thread-inbox"; import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -38,8 +39,15 @@ type LayoutInput = Parameters[0]; function observeInbox( threads: Parameters[0], _expiredGraceVersion: number, + stackedActionThreads: ReadonlyMap, ) { - return threadListInboxReturns.observe(threads); + return threadListInboxReturns.observe(threads, { + isWorking: (thread) => + isThreadWorkingOrSourceControlBusy( + thread, + isThreadStackedActionRunning(thread, stackedActionThreads), + ), + }); } /** @@ -81,14 +89,10 @@ export function useThreadListV2(input: { ); const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); const inbox = useMemo( - () => observeInbox(workingShelfEnabled ? threads : null, inboxVersion), - [threads, workingShelfEnabled, inboxVersion], + () => observeInbox(workingShelfEnabled ? threads : null, inboxVersion, stackedActionThreads), + [threads, workingShelfEnabled, inboxVersion, stackedActionThreads], ); - const isWorking = useMemo(() => { - const inboxIsWorking = inbox.isWorking; - return (thread: Parameters[0]) => - inboxIsWorking(thread) || isThreadStackedActionRunning(thread, stackedActionThreads); - }, [inbox.isWorking, stackedActionThreads]); + const isWorking = inbox.isWorking; // Compute move availability per section; per-row planning is quadratic. const moveAvailability = useMemo(() => { const sectionAvailability = (section: "pinned" | "active") => diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 933237f40c46..bcafe6836116 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1658,6 +1658,11 @@ describe("resolveSidebarFolderDot", () => { expect(resolveSidebarFolderDot([idle, working])).toBe("working"); expect(resolveSidebarFolderDot([working, unread])).toBe("unread"); expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); + expect( + resolveSidebarFolderDot([idle], { + sourceControlStatusForThread: () => "merging", + }), + ).toBe("working"); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 669df1bcbc96..ab5230bd03e2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -915,10 +915,22 @@ export function resolveSidebarThreadStatus( * The dot on a closed boring-sidebar folder: the most urgent state among its * hidden threads. Waiting threads stay quiet, like their rows. */ -export function resolveSidebarFolderDot( - threads: ReadonlyArray, +export function resolveSidebarFolderDot< + T extends ThreadStatusInput & SidebarThreadStatusInput = ThreadStatusInput & + SidebarThreadStatusInput, +>( + threads: ReadonlyArray, + options?: { + readonly sourceControlStatusForThread?: ( + thread: T, + ) => "committing" | "pushing" | "submitting" | "merging" | null; + }, ): "attention" | "failed" | "unread" | "working" | null { - const statuses = new Set(threads.map((thread) => resolveSidebarThreadStatus(thread))); + const statusOf = (thread: T) => + resolveSidebarThreadStatus(thread, { + sourceControlStatus: options?.sourceControlStatusForThread?.(thread) ?? null, + }); + const statuses = new Set(threads.map(statusOf)); if ( statuses.has("input") || statuses.has("approval") || @@ -930,7 +942,7 @@ export function resolveSidebarFolderDot( if (statuses.has("failed")) return "failed"; // Rows show Done only for ready threads, so the dot follows the same rule. const hasUnread = threads.some( - (thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread), + (thread) => statusOf(thread) === "ready" && hasUnseenCompletion(thread), ); if (hasUnread) return "unread"; if ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fe8e8c93f9ab..9c5fd3bae8a2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -32,7 +32,7 @@ import { } from "@t3tools/client-runtime/state/thread-settled"; import { createInboxReturnTracker, - isThreadWorking, + isThreadWorkingOrSourceControlBusy, } from "@t3tools/client-runtime/state/thread-inbox"; import { isThreadStackedActionRunning } from "@t3tools/client-runtime/state/vcs"; import { vcsActionManager } from "~/state/vcs"; @@ -630,13 +630,24 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); - const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null); + const sourceControlWorking = ( + thread: Parameters[0] & { + readonly environmentId: EnvironmentThreadShell["environmentId"]; + readonly id: EnvironmentThreadShell["id"]; + }, + ) => + isThreadWorkingOrSourceControlBusy( + thread, + isThreadStackedActionRunning(thread, stackedActionThreads), + ); + const inboxObservation = inboxReturns.observe(workingShelfEnabled ? threads : null, { + isWorking: sourceControlWorking, + }); const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const working: EnvironmentThreadShell[] = []; const threadIsWorking = (thread: EnvironmentThreadShell, manuallyMoved = false) => - (manuallyMoved ? isThreadWorking(thread) : inboxObservation.isWorking(thread)) || - isThreadStackedActionRunning(thread, stackedActionThreads); + manuallyMoved ? sourceControlWorking(thread) : inboxObservation.isWorking(thread); const inbox = (thread: EnvironmentThreadShell, manuallyMoved = false) => workingShelfEnabled && threadIsWorking(thread, manuallyMoved) ? working : active; const snoozed: EnvironmentThreadShell[] = []; diff --git a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx index 38b1e53bf057..a08294ed16a9 100644 --- a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx @@ -1,10 +1,13 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { resolveSourceControlThreadStatus } from "@t3tools/client-runtime/state/vcs"; +import { useAtomValue } from "@effect/atom-react"; import { ChevronDownIcon, SquarePenIcon } from "lucide-react"; import { memo } from "react"; import { cn } from "~/lib/utils"; import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import { vcsActionManager } from "../../state/vcs"; import { useUiStateStore } from "../../uiStateStore"; import { resolveSidebarFolderDot, resolveThreadLastVisitedAt } from "../Sidebar.logic"; import { ProjectFavicon } from "../ProjectFavicon"; @@ -46,6 +49,7 @@ export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHead const { group, collapsed, hiddenThreads } = props; const rowId = sidebarFolderRowId(group.projectKey); const localLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); const dot = collapsed ? resolveSidebarFolderDot( hiddenThreads.map((thread) => ({ @@ -57,6 +61,12 @@ export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHead ], ), })), + { + sourceControlStatusForThread: (thread) => + resolveSourceControlThreadStatus( + stackedActionThreads.get(`${thread.environmentId}:${thread.id}`), + ), + }, ) : null; return ( diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 35c7fa8355e3..0a24a2eed744 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { createInboxReturnTracker, isThreadWorking, + isThreadWorkingOrSourceControlBusy, sortInboxThreadsByReturn, sortWorkingThreadsBySend, } from "./threadInbox.ts"; @@ -34,6 +35,31 @@ function thread(id: string, working: boolean) { }; } +describe("isThreadWorkingOrSourceControlBusy", () => { + it("keeps approval and user-input out of the Working shelf during a source-control action", () => { + expect(isThreadWorkingOrSourceControlBusy(thread("a", false), true)).toBe(true); + expect( + isThreadWorkingOrSourceControlBusy( + { ...thread("a", false), hasPendingApprovals: true }, + true, + ), + ).toBe(false); + expect( + isThreadWorkingOrSourceControlBusy( + { ...thread("a", false), hasPendingUserInput: true }, + true, + ), + ).toBe(false); + expect( + isThreadWorkingOrSourceControlBusy( + { ...thread("a", false), pendingSubagentRequest: "approval" }, + true, + ), + ).toBe(false); + expect(isThreadWorkingOrSourceControlBusy(thread("a", true), false)).toBe(true); + }); +}); + describe("createInboxReturnTracker", () => { beforeEach(() => { vi.useFakeTimers(); @@ -43,6 +69,20 @@ describe("createInboxReturnTracker", () => { vi.clearAllTimers(); vi.useRealTimers(); }); + it("stamps a return when a custom source-control busy predicate clears", () => { + const tracker = createInboxReturnTracker(); + let sourceControlBusy = true; + const isWorking = (candidate: Parameters[0]) => + isThreadWorkingOrSourceControlBusy(candidate, sourceControlBusy); + tracker.observe([thread("a", false)], { isWorking }); + expect(tracker.isWorking(thread("a", false))).toBe(true); + sourceControlBusy = false; + tracker.observe([thread("a", false)], { isWorking }); + expect(tracker.isWorking(thread("a", false))).toBe(true); + vi.advanceTimersByTime(2_000); + expect(tracker.isWorking(thread("a", false))).toBe(false); + expect(tracker.returnedAt(thread("a", false))).toBe(12_000); + }); it("stamps a thread when it stops working, but never on the first observation", () => { const tracker = createInboxReturnTracker(); const first = tracker.observe([thread("a", true), thread("b", false)]); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 2669416db42f..ddc6d7aa6fd1 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -30,6 +30,21 @@ export function isThreadWorking(thread: WorkingThreadInput): boolean { return !(thread.interactionMode === "plan" && thread.hasActionableProposedPlan && runSettled); } +/** + * Working-shelf placement for agent work or an in-flight source-control action. + * Approvals and user-input stay in the active list, matching `isThreadWorking`. + */ +export function isThreadWorkingOrSourceControlBusy( + thread: WorkingThreadInput, + sourceControlBusy: boolean, +): boolean { + if (isThreadWorking(thread)) return true; + if (!sourceControlBusy) return false; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; + if (thread.pendingSubagentRequest != null) return false; + return true; +} + type InboxThreadInput = Pick< EnvironmentThreadShell, "id" | "environmentId" | "createdAt" | "unsettledAt" | "latestRun" @@ -166,16 +181,22 @@ export function createInboxReturnTracker() { }; }, getSnapshot: () => version, - observe(threads: readonly TrackedInboxThread[] | null): { + observe( + threads: readonly TrackedInboxThread[] | null, + options?: { + readonly isWorking?: (thread: WorkingThreadInput & ThreadKeyInput) => boolean; + }, + ): { readonly isWorking: (thread: WorkingThreadInput & ThreadKeyInput) => boolean; readonly returnedAt: (thread: ThreadKeyInput) => number | undefined; } { + const resolveWorking = options?.isWorking ?? isThreadWorking; if (threads === null) { clearTimeout(timer); timer = undefined; states.clear(); returns.clear(); - return { isWorking: isThreadWorking, returnedAt: () => undefined }; + return { isWorking: resolveWorking, returnedAt: () => undefined }; } const present = new Set(); const at = now(); @@ -185,7 +206,7 @@ export function createInboxReturnTracker() { const key = keyOf(thread); present.add(key); const previous = states.get(key); - const actual = isThreadWorking(thread); + const actual = resolveWorking(thread); const placement = actual || previous?.working ? JSON.stringify([ @@ -234,7 +255,7 @@ export function createInboxReturnTracker() { states.forEach((state, key) => observedWorking.set(key, state.working)); const observedReturns = new Map(returns); return { - isWorking: (thread) => observedWorking.get(keyOf(thread)) ?? isThreadWorking(thread), + isWorking: (thread) => observedWorking.get(keyOf(thread)) ?? resolveWorking(thread), returnedAt: (thread) => observedReturns.get(keyOf(thread)), }; }, From a9782d03431a01c31fd453017d045f865c94725a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 08:46:48 +0000 Subject: [PATCH 07/11] perf: sidebar rows subscribe to their own source-control busy state --- apps/mobile/src/features/threads/thread-list-v2-items.tsx | 5 +++-- apps/web/src/components/sidebar/SidebarThreadRow.tsx | 5 +++-- packages/client-runtime/src/state/vcsAction.ts | 8 ++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 7c51568f063e..f6c604aacf0d 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -793,8 +793,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const selected = props.selected === true; const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); - const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); - const stackedAction = stackedActionThreads.get(`${thread.environmentId}:${thread.id}`); + const stackedAction = useAtomValue( + vcsActionManager.stackedActionThreadAtom(thread.environmentId, thread.id), + ); const sourceControlStatus = resolveSourceControlThreadStatus(stackedAction); const status = resolveThreadListV2Status(thread, { sourceControlStatus }); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 5a93e494640c..bdb834b0bcd7 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -722,8 +722,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : null; const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const stackedActionThreads = useAtomValue(vcsActionManager.stackedActionThreadsAtom); - const stackedAction = stackedActionThreads.get(`${thread.environmentId}:${thread.id}`); + const stackedAction = useAtomValue( + vcsActionManager.stackedActionThreadAtom(thread.environmentId, thread.id), + ); const sourceControlStatus = resolveSourceControlThreadStatus(stackedAction); const status = resolveSidebarThreadStatus(thread, { sourceControlStatus }); const workingStartedAt = diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index e96383247b9b..b15c0a5dce43 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -227,6 +227,12 @@ export function vcsStackedActionThreadKey( return `${environmentId}:${threadId}`; } +const vcsStackedActionThreadAtom = Atom.family((key: string) => + Atom.make((get) => get(vcsStackedActionThreadsAtom).get(key)).pipe( + Atom.withLabel(`vcs-stacked-action-thread:${key}`), + ), +); + export function isThreadStackedActionRunning( thread: { readonly environmentId: EnvironmentIdType; readonly id: ThreadId }, running: ReadonlyMap | ReadonlySet, @@ -700,6 +706,8 @@ export function createVcsActionManager( return { stateAtom: getVcsActionStateAtom, stackedActionThreadsAtom: vcsStackedActionThreadsAtom, + stackedActionThreadAtom: (environmentId: EnvironmentIdType, threadId: ThreadId) => + vcsStackedActionThreadAtom(vcsStackedActionThreadKey(environmentId, threadId)), runStackedAction: (target: VcsActionTarget) => getRunStackedActionCommand(target), track: async ( registry: AtomRegistry.AtomRegistry, From 2a527a761fd18fdc9e7edf6cf6cd18e81fc8cccd Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 09:02:46 +0000 Subject: [PATCH 08/11] fix: keep guest requests out of Working and match branch PRs on merge --- .../src/components/sidebar/SidebarProjectFolderHeader.tsx | 7 +++++-- packages/client-runtime/src/state/threadInbox.test.ts | 6 ++++++ packages/client-runtime/src/state/threadInbox.ts | 5 +++-- packages/client-runtime/src/state/vcsAction.test.ts | 8 +++++++- packages/client-runtime/src/state/vcsAction.ts | 6 +++++- 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx index a08294ed16a9..c2141173ff7f 100644 --- a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx @@ -1,6 +1,9 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { resolveSourceControlThreadStatus } from "@t3tools/client-runtime/state/vcs"; +import { + resolveSourceControlThreadStatus, + vcsStackedActionThreadKey, +} from "@t3tools/client-runtime/state/vcs"; import { useAtomValue } from "@effect/atom-react"; import { ChevronDownIcon, SquarePenIcon } from "lucide-react"; import { memo } from "react"; @@ -64,7 +67,7 @@ export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHead { sourceControlStatusForThread: (thread) => resolveSourceControlThreadStatus( - stackedActionThreads.get(`${thread.environmentId}:${thread.id}`), + stackedActionThreads.get(vcsStackedActionThreadKey(thread.environmentId, thread.id)), ), }, ) diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 0a24a2eed744..ae34821d1620 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -50,6 +50,12 @@ describe("isThreadWorkingOrSourceControlBusy", () => { true, ), ).toBe(false); + expect( + isThreadWorkingOrSourceControlBusy( + { ...thread("a", false), pendingGuestMessageCount: 1 }, + true, + ), + ).toBe(false); expect( isThreadWorkingOrSourceControlBusy( { ...thread("a", false), pendingSubagentRequest: "approval" }, diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 23d2fbd35473..456a8621b1f2 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -32,16 +32,17 @@ export function isThreadWorking(thread: WorkingThreadInput): boolean { /** * Working-shelf placement for agent work or an in-flight source-control action. - * Approvals and user-input stay in the active list, matching `isThreadWorking`. + * Approvals, user-input, and guest requests stay in the active list. */ export function isThreadWorkingOrSourceControlBusy( - thread: WorkingThreadInput, + thread: WorkingThreadInput & Pick, sourceControlBusy: boolean, ): boolean { if (isThreadWorking(thread)) return true; if (!sourceControlBusy) return false; if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.pendingSubagentRequest != null) return false; + if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; return true; } diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index dfa0e61c9608..49156fb38aad 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -770,6 +770,12 @@ describe("threadIdsLinkedToPullRequest", () => { id: threadB, pullRequests: [{ repository: "owner/repo", number: 8 }], }, + { + environmentId, + id: ThreadId.make("thread-d"), + pullRequests: [], + branchPullRequest: { repository: "owner/repo", number: 7 }, + }, { environmentId: EnvironmentId.make("other"), id: threadC, @@ -779,7 +785,7 @@ describe("threadIdsLinkedToPullRequest", () => { environmentId, { repository: "owner/repo", number: 7, host: "github.com" }, ), - ).toEqual([threadA]); + ).toEqual([threadA, ThreadId.make("thread-d")]); }); }); diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index b15c0a5dce43..3e71910e9eb4 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -281,6 +281,7 @@ export function threadIdsLinkedToPullRequest( readonly repository: string; readonly number: number; }>; + readonly branchPullRequest?: { readonly repository: string; readonly number: number } | null; }>, environmentId: EnvironmentIdType, reference: { @@ -298,7 +299,10 @@ export function threadIdsLinkedToPullRequest( link.repository === reference.repository && (reference.host === undefined || link.host === reference.host), ); - if (linked) ids.push(shell.id); + const branchLinked = + shell.branchPullRequest?.number === reference.number && + shell.branchPullRequest.repository === reference.repository; + if (linked || branchLinked) ids.push(shell.id); } return ids; } From 359633fc7fc252281d5b7f56690e2ff41fd00cfa Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 09:18:31 +0000 Subject: [PATCH 09/11] fix: match the branch PR host when marking merge activity --- .../client-runtime/src/state/vcsAction.test.ts | 16 +++++++++++++++- packages/client-runtime/src/state/vcsAction.ts | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 49156fb38aad..151a48db1132 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -774,7 +774,21 @@ describe("threadIdsLinkedToPullRequest", () => { environmentId, id: ThreadId.make("thread-d"), pullRequests: [], - branchPullRequest: { repository: "owner/repo", number: 7 }, + branchPullRequest: { + repository: "owner/repo", + number: 7, + url: "https://github.com/owner/repo/pull/7", + }, + }, + { + environmentId, + id: ThreadId.make("thread-e"), + pullRequests: [], + branchPullRequest: { + repository: "owner/repo", + number: 7, + url: "https://gitlab.com/owner/repo/-/merge_requests/7", + }, }, { environmentId: EnvironmentId.make("other"), diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 3e71910e9eb4..a23f4b481725 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -20,6 +20,7 @@ import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import * as Persistence from "../platform/persistence.ts"; import { runStreamGuarded, RpcPermissionGuard } from "../rpc/client.ts"; +import { legacyThreadPullRequestKey, threadPullRequestKeysEqual } from "../threadPullRequests.ts"; import { createRuntimeCommand, runStreamInEnvironment, @@ -281,7 +282,11 @@ export function threadIdsLinkedToPullRequest( readonly repository: string; readonly number: number; }>; - readonly branchPullRequest?: { readonly repository: string; readonly number: number } | null; + readonly branchPullRequest?: { + readonly repository: string; + readonly number: number; + readonly url: string; + } | null; }>, environmentId: EnvironmentIdType, reference: { @@ -301,7 +306,12 @@ export function threadIdsLinkedToPullRequest( ); const branchLinked = shell.branchPullRequest?.number === reference.number && - shell.branchPullRequest.repository === reference.repository; + shell.branchPullRequest.repository === reference.repository && + (reference.host === undefined || + threadPullRequestKeysEqual(legacyThreadPullRequestKey(shell.branchPullRequest), { + ...reference, + host: reference.host, + })); if (linked || branchLinked) ids.push(shell.id); } return ids; From bc36b11634c52e52c5664fa269f6c9383b26e82d Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 11 Nov 2026 09:46:55 -0800 Subject: [PATCH 10/11] fix: restore mobile third-party license notices for knip Co-authored-by: Cursor --- third-party-licenses.config.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 0ce031a30f35..aa4dad90028a 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -841,6 +841,26 @@ "name": "iobuffer", "license": "MIT", "sourceUrl": "https://github.com/image-js/iobuffer" + }, + { + "name": "deprecated-react-native-prop-types", + "version": "2.3.0", + "license": "MIT", + "sourceUrl": "https://github.com/facebookarchive/react-native-deprecated-modules", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] + } + }, + { + "name": "@react-native/normalize-color", + "version": "2.1.0", + "license": "MIT", + "sourceUrl": "https://github.com/facebook/react-native/tree/main/packages/normalize-color", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) Meta Platforms, Inc. and affiliates."] + } } ] } From 7be9b4d46ebe4e12793199979e6121522f3c4956 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 11 Nov 2026 09:46:57 -0800 Subject: [PATCH 11/11] fix: keep guest requests out of Working when runtime is idle Co-authored-by: Cursor --- .../src/state/threadInbox.test.ts | 18 ++++++++++++++++++ .../client-runtime/src/state/threadInbox.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index ae34821d1620..984e8f8f47f8 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -64,6 +64,24 @@ describe("isThreadWorkingOrSourceControlBusy", () => { ).toBe(false); expect(isThreadWorkingOrSourceControlBusy(thread("a", true), false)).toBe(true); }); + + it("keeps guest requests out of Working when the runtime is idle", () => { + const idle = { + ...thread("a", false), + runtime: { + status: "idle" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-06-01T00:00:00.000Z", + }, + pendingGuestMessageCount: 1, + }; + expect(isThreadWorking(idle)).toBe(true); + expect(isThreadWorkingOrSourceControlBusy(idle, false)).toBe(false); + expect(isThreadWorkingOrSourceControlBusy(idle, true)).toBe(false); + }); }); describe("createInboxReturnTracker", () => { diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 456a8621b1f2..ab84827ecc85 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -38,11 +38,11 @@ export function isThreadWorkingOrSourceControlBusy( thread: WorkingThreadInput & Pick, sourceControlBusy: boolean, ): boolean { + if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; if (isThreadWorking(thread)) return true; if (!sourceControlBusy) return false; if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false; if (thread.pendingSubagentRequest != null) return false; - if ((thread.pendingGuestMessageCount ?? 0) > 0) return false; return true; }