Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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(
Expand Down
47 changes: 44 additions & 3 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -119,6 +122,30 @@ const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> =
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",
Expand Down Expand Up @@ -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" : ""}`;
Expand Down Expand Up @@ -1248,13 +1287,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
<Text
className={cn(
"text-xs font-t3-bold",
(status === "working" || isSourceControlStatus || status === "waiting") &&
"tabular-nums",
selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className,
)}
>
{statusLabel.label}
{status === "working" ? (
{status === "working" || isSourceControlStatus ? (
<ThreadListV2Duration
startedAt={resolveThreadWorkingStartedAt(thread)}
startedAt={workingStartedAt}
leadingSpace={statusLabel.label !== null}
/>
) : status === "waiting" ? (
Expand Down
10 changes: 9 additions & 1 deletion apps/mobile/src/features/threads/thread-row-emphasis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
}
32 changes: 32 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export type ThreadListV2Status =
| "input"
| "request"
| "working"
| "committing"
| "pushing"
| "submitting"
| "merging"
| "waiting"
| "failed"
| "limited"
Expand Down Expand Up @@ -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";
Expand All @@ -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";
}

Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/features/threads/useThreadListV2.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 18 additions & 5 deletions apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -35,8 +39,15 @@ type LayoutInput = Parameters<typeof buildThreadListV2Items>[0];
function observeInbox(
threads: Parameters<typeof threadListInboxReturns.observe>[0],
_expiredGraceVersion: number,
stackedActionThreads: ReadonlyMap<string, unknown>,
) {
return threadListInboxReturns.observe(threads);
return threadListInboxReturns.observe(threads, {
isWorking: (thread) =>
isThreadWorkingOrSourceControlBusy(
thread,
isThreadStackedActionRunning(thread, stackedActionThreads),
),
});
}

/**
Expand Down Expand Up @@ -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") =>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -180,7 +193,7 @@ export function useThreadListV2(input: {
workingShelfEnabled,
input.workingShelfExpanded,
inbox.returnedAt,
inbox.isWorking,
isWorking,
input.snoozedShelfExpanded,
input.settledShelfExpanded,
input.selectedThreadKey,
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});
});

Expand Down
Loading
Loading