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 d724d8056e95..f6c604aacf0d 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 { 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"; 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"; @@ -57,6 +59,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, @@ -119,6 +122,30 @@ 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", + }, + 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", @@ -766,14 +793,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const selected = props.selected === true; const rowAppearance = getThreadListV2RowAppearance(theme, sidebarPane, selected); - const status = resolveThreadListV2Status(thread); + const stackedAction = useAtomValue( + vcsActionManager.stackedActionThreadAtom(thread.environmentId, thread.id), + ); + const sourceControlStatus = resolveSourceControlThreadStatus(stackedAction); + const status = resolveThreadListV2Status(thread, { sourceControlStatus }); const isUnread = status === "ready" && threadHasUnseenCompletion(thread); + const isSourceControlStatus = + status === "committing" || + status === "pushing" || + status === "submitting" || + status === "merging"; const workingLabel = STATUS_LABEL_BY_STATUS[status]; const showsWoke = status === "ready" && props.wokeAt !== null; const statusLabel = (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}${providerNameLabel ? `, ${providerNameLabel}` : ""}${props.hasQueuedMessages ? ", messages queued to send" : ""}${isRegeneratingTitle ? ", regenerating title" : ""}`; @@ -1248,13 +1287,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { {statusLabel.label} - {status === "working" ? ( + {status === "working" || isSourceControlStatus ? ( ) : status === "waiting" ? ( diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.ts b/apps/mobile/src/features/threads/thread-row-emphasis.ts index d78e5f0a14b7..d59108c7e0f7 100644 --- a/apps/mobile/src/features/threads/thread-row-emphasis.ts +++ b/apps/mobile/src/features/threads/thread-row-emphasis.ts @@ -4,5 +4,13 @@ export function shouldRecedeThreadRow(input: { readonly status: ThreadListV2Status; readonly selected: boolean; }): boolean { - return !input.selected && (input.status === "working" || input.status === "waiting"); + return ( + !input.selected && + (input.status === "working" || + 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 c848e063e2be..789d7cf36055 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -186,6 +186,38 @@ describe("resolveThreadListV2Status", () => { expect(resolveThreadListV2Status({ ...guest, hasPendingApprovals: true })).toBe("approval"); }); + it("labels in-flight commit, push, PR, or merge only when nothing else owns the row", () => { + const thread = presentThreadShell( + environmentId, + decodeThreadShell(makeRawThreadShell({ status: "completed" })), + ); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "committing" })).toBe( + "committing", + ); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "pushing" })).toBe("pushing"); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "submitting" })).toBe( + "submitting", + ); + expect(resolveThreadListV2Status(thread, { sourceControlStatus: "merging" })).toBe("merging"); + expect( + resolveThreadListV2Status( + { ...thread, hasPendingUserInput: 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([ { 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 7854f9d7fdb8..2d485ccede97 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -68,6 +68,10 @@ export type ThreadListV2Status = | "input" | "request" | "working" + | "committing" + | "pushing" + | "submitting" + | "merging" | "waiting" | "failed" | "limited" @@ -267,6 +271,9 @@ export function resolveThreadListV2Status( | "runtime" | "blockedOnThreadWait" >, + options?: { + readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | "merging" | null; + }, ): ThreadListV2Status { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; @@ -293,6 +300,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/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..0c1b15107d80 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -1,10 +1,14 @@ 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"; 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, @@ -35,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), + ), + }); } /** @@ -76,10 +87,12 @@ export function useThreadListV2(input: { threadListInboxReturns.subscribe, threadListInboxReturns.getSnapshot, ); + 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 = inbox.isWorking; // Compute move availability per section; per-row planning is quadratic. const moveAvailability = useMemo(() => { const sectionAvailability = (section: "pinned" | "active") => @@ -148,7 +161,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 +193,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..bcafe6836116 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -310,6 +310,26 @@ describe("resolveSidebarThreadStatus", () => { }), ).toBe("waiting"); }); + + it("labels in-flight commit, push, PR, or merge 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, { sourceControlStatus: "merging" })).toBe("merging"); + expect( + resolveSidebarThreadStatus( + { ...idle, hasPendingUserInput: true }, + { sourceControlStatus: "committing" }, + ), + ).toBe("input"); + expect( + resolveSidebarThreadStatus({ ...idle, runtime }, { sourceControlStatus: "pushing" }), + ).toBe("working"); + }); }); describe("searchSidebarThreads", () => { @@ -1638,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 5ed1320b59e5..36cbaa38d78b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -912,6 +912,10 @@ export type SidebarThreadStatus = | "request" | "preparing" | "working" + | "committing" + | "pushing" + | "submitting" + | "merging" | "waiting" | "failed" | "limited" @@ -932,7 +936,15 @@ 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 === "merging" || + input.status === "waiting" + ) { return true; } if (input.status === "ready" || input.status === "approval") { @@ -951,7 +963,12 @@ type SidebarThreadStatusInput = Pick< | "blockedOnThreadWait" >; -export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus { +export function resolveSidebarThreadStatus( + thread: SidebarThreadStatusInput, + options?: { + readonly sourceControlStatus?: "committing" | "pushing" | "submitting" | "merging" | null; + }, +): SidebarThreadStatus { if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") { return "approval"; } @@ -980,6 +997,10 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si 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"; } @@ -987,10 +1008,22 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si * 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(resolveSidebarThreadStatus)); + 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") || @@ -1002,10 +1035,19 @@ 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 (statuses.has("working") || statuses.has("preparing")) return "working"; + if ( + statuses.has("working") || + statuses.has("preparing") || + statuses.has("committing") || + statuses.has("pushing") || + statuses.has("submitting") || + statuses.has("merging") + ) { + return "working"; + } return null; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8fa58d8456f7..edbe568185d1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -32,8 +32,10 @@ import { import { asLaunchingThread, createInboxReturnTracker, - isThreadWorking, + isThreadWorkingOrSourceControlBusy, } 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, @@ -355,6 +357,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. @@ -444,6 +448,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); @@ -484,7 +489,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 } = @@ -619,18 +625,30 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, + stackedActionThreads, } = 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 ? sourceControlWorking(thread) : inboxObservation.isWorking(thread); 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/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index 3f867992d75c..f3960a5de407 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 89061c59ea4c..a33f6e3a97c2 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -23,12 +23,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; @@ -48,6 +50,7 @@ function ThreadDetailsPrLinkRow({ return ( ; + const currentRow = ( + + ); const rest = currentLink === null ? [] @@ -108,6 +117,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 a4ce4ae7431e..dd6bd2b5721c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -56,6 +56,8 @@ import { isCommandPaletteOpen } from "~/commandPaletteBus"; import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { + beginPullRequestMergeBusy, + endPullRequestMergeBusy, usePullRequestDefaultMergeMethodResolver, writeHandoffToComposer, } from "./usePullRequestActions"; @@ -780,6 +782,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: { @@ -788,6 +803,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 2cc81c488536..cc63d865a342 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/SidebarProjectFolderHeader.tsx b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx index 38b1e53bf057..c2141173ff7f 100644 --- a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx @@ -1,10 +1,16 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +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"; 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 +52,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 +64,12 @@ export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHead ], ), })), + { + sourceControlStatusForThread: (thread) => + resolveSourceControlThreadStatus( + stackedActionThreads.get(vcsStackedActionThreadKey(thread.environmentId, thread.id)), + ), + }, ) : null; return ( diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 05e898420047..bdb834b0bcd7 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 { asLaunchingThread } from "@t3tools/client-runtime/state/thread-inbox"; import { ThreadHoverCard, ThreadHoverCardPopup } from "../ThreadHoverCard"; import { ThreadTerminalStatusButton } from "../ThreadStatusIndicators"; @@ -71,7 +71,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 { 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"; import { formatRelativeTimeLabel, parseTimestampDate } from "../../timestampFormat"; @@ -721,7 +722,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP : null; const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); - const status = resolveSidebarThreadStatus(thread); + const stackedAction = useAtomValue( + vcsActionManager.stackedActionThreadAtom(thread.environmentId, thread.id), + ); + const sourceControlStatus = resolveSourceControlThreadStatus(stackedAction); + const status = resolveSidebarThreadStatus(thread, { sourceControlStatus }); + 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 = @@ -735,15 +743,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isActive: props.isActive, isSelected, }); + const isSourceControlStatus = + status === "committing" || + status === "pushing" || + status === "submitting" || + status === "merging"; 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" + : status === "merging" + ? "Merging" + : thread.goal?.status === "active" + ? "Goal" + : "Working", icon: "working" as const, className: "text-info-foreground", } @@ -977,7 +998,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", @@ -1515,9 +1536,11 @@ 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 ? ( - + ) : status === "waiting" ? ( diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e6..07f0e465ae48 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,9 +134,10 @@ 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 -there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or +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, +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/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 35c7fa8355e3..984e8f8f47f8 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,55 @@ 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), pendingGuestMessageCount: 1 }, + true, + ), + ).toBe(false); + expect( + isThreadWorkingOrSourceControlBusy( + { ...thread("a", false), pendingSubagentRequest: "approval" }, + true, + ), + ).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", () => { beforeEach(() => { vi.useFakeTimers(); @@ -43,6 +93,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 d3de3b0bc4c2..ab84827ecc85 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -30,6 +30,22 @@ 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, user-input, and guest requests stay in the active list. + */ +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; + return true; +} + /** * A thread its own client is launching reads as preparing until its first run lands. * The server commits the thread before its run, so the list would otherwise place it twice. @@ -185,16 +201,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(); @@ -204,7 +226,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([ @@ -253,7 +275,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)), }; }, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index d4a1355fb4ff..151a48db1132 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -38,13 +38,18 @@ import { createVcsActionManager, createVcsActionTransportId, EMPTY_VCS_ACTION_STATE, + isThreadStackedActionRunning, normalizeVcsActionProgressEvent, parseVcsActionTargetKey, + resolveSourceControlThreadStatus, + threadIdsLinkedToPullRequest, + 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 +540,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 +551,7 @@ describe("vcsActionState", () => { manager.runStackedAction(targetKey).run(registry, { actionId: failedActionId, action, + threadId, }), ); @@ -555,6 +562,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 +583,224 @@ 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 afterPhase = 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.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, + }), + ), + ), + ), + ); + }), + ), + } 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"); + 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); + expect(registry.get(manager.stateAtom(targetKey))).toMatchObject({ + isRunning: true, + threadId, + actionId: runningActionId, + }); + const busy = registry.get(manager.stackedActionThreadsAtom); + 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); + }), + ), + ); +}); + +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({ ...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, + id: ThreadId.make("thread-d"), + pullRequests: [], + 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"), + id: threadC, + pullRequests: [{ repository: "owner/repo", number: 7, host: "github.com" }], + }, + ], + environmentId, + { repository: "owner/repo", number: 7, host: "github.com" }, + ), + ).toEqual([threadA, ThreadId.make("thread-d")]); + }); }); vi.mock("./session.ts", () => ({ diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 5382e6116447..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, @@ -48,6 +49,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 +59,35 @@ export interface VcsActionState { readonly error: string | null; } +/** 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: SourceControlBusyAction; + readonly phase: GitActionProgressPhase | null; + readonly startedAtMs: number; +} + +/** 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"; + 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; @@ -71,6 +102,7 @@ export interface BeginVcsActionInput { readonly operation: VcsActionOperation; readonly label: string; readonly actionId?: string; + readonly threadId?: ThreadId; } export interface RunVcsStackedActionInput { @@ -153,6 +185,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 +213,113 @@ 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}`; +} + +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, +): boolean { + return running.has(vcsStackedActionThreadKey(thread.environmentId, thread.id)); +} + +export function markSourceControlThreadBusy( + registry: AtomRegistry.AtomRegistry, + presence: VcsStackedActionThreadPresence, +): void { + const key = vcsStackedActionThreadKey(presence.environmentId, presence.threadId); + const current = registry.get(vcsStackedActionThreadsAtom); + 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); +} + +export function clearSourceControlThreadBusy( + 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); +} + +export function threadIdsLinkedToPullRequest( + shells: Iterable<{ + readonly environmentId: EnvironmentIdType; + readonly id: ThreadId; + readonly pullRequests: ReadonlyArray<{ + readonly host?: string | undefined; + readonly repository: string; + readonly number: number; + }>; + readonly branchPullRequest?: { + readonly repository: string; + readonly number: number; + readonly url: string; + } | null; + }>, + 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), + ); + const branchLinked = + shell.branchPullRequest?.number === reference.number && + 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; +} + +const markStackedActionThread = markSourceControlThreadBusy; +const clearStackedActionThread = clearSourceControlThreadBusy; + function getVcsActionTargetKey(target: VcsActionTarget): string | null { if (target.environmentId === null || target.cwd === null) { return null; @@ -216,6 +356,7 @@ export function beginVcsActionState( isRunning: true, operation: input.operation, actionId, + threadId: input.threadId ?? null, currentLabel: input.label, currentPhaseLabel: input.label, phaseStartedAtMs: startedAt, @@ -462,8 +603,20 @@ export function createVcsActionManager( operation: "run_change_request", label: "Running source control action", actionId: input.actionId, + ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), }), ); + const startedAtMs = nowMs(); + if (input.threadId !== undefined) { + markStackedActionThread(registry, { + environmentId: target.environmentId, + threadId: input.threadId, + actionId: input.actionId, + action: input.action, + phase: null, + startedAtMs, + }); + } const rpcInput: GitRunStackedActionInput = { actionId: transportActionId, @@ -498,6 +651,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); @@ -511,6 +674,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 +719,9 @@ 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, 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."] + } } ] }