Skip to content
Merged
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
25 changes: 24 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts";
import {
deriveLatestThreadRun,
deriveResumableRunId,
deriveThreadRuntime,
} from "@t3tools/client-runtime/state/thread-execution";
import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints";
import { COMPACT_COMMAND_PATTERN } from "@t3tools/client-runtime/state/context-window";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import {
backgroundWorkHoldsCompletion,
derivePendingBackgroundWork,
} from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import {
latestUnheldRun,
usageLimitRunPresentedAsLatest,
Expand Down Expand Up @@ -1446,13 +1450,32 @@ export function presentThreadShellFromProjection(
)?.goal ?? null,
itemCount: projection.turnItems.length,
visibleItemCount: projection.visibleTurnItems.length,
// The structure carries no roster; derive it so it agrees with the runtime below.
pendingBackgroundTasks: deriveThreadPendingBackgroundTasks(projection),
updatedAt,
}),
latestRun,
runtime,
};
}

/**
* The run Continue resumes. An interrupted run is not offered while a pull request watch or
* other awaited background work holds the thread: it is waiting, not stopped.
*/
export function deriveContinuableRunId(
projection: ThreadStructure,
runtime: Parameters<typeof deriveResumableRunId>[1],
): RunId | null {
const runId = deriveResumableRunId(projection, runtime);
if (runId === null) return null;
const run = projection.runs.find((candidate) => candidate.id === runId);
return run?.status === "interrupted" &&
backgroundWorkHoldsCompletion(deriveThreadPendingBackgroundTasks(projection))
? null
: runId;
}

/** Background work the provider still runs after the latest run ended. */
export function deriveThreadPendingBackgroundTasks(projection: ThreadStructure | null) {
if (projection === null) return [];
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { configuredHostedAppUrl } from "../hostedPairing";
import { announcedRun, isDetailBehindShell } from "./chat/threadActivityAnnouncement";
import {
deriveAvailableRevertTurnCounts,
deriveContinuableRunId,
deriveThreadPendingBackgroundTasks,
presentThreadShellFromProjection,
} from "./ChatView.logic";
Expand Down Expand Up @@ -76,7 +77,6 @@ import {
deriveProviderSubagentStatus,
deriveProviderExtensionStatuses,
deriveReportedModelSelection,
deriveResumableRunId,
formatModelSelectionEffort,
deriveRunlessWorkStartedAt,
deriveThreadActivityRun,
Expand Down Expand Up @@ -813,7 +813,7 @@ export default function ChatView(props: ChatViewProps) {
const resumableRunId =
!isServerThread || serverProjection === null
? null
: deriveResumableRunId(serverProjection, serverRuntime);
: deriveContinuableRunId(serverProjection, serverRuntime);
const parentSubagentThreadId =
activeThread?.lineage.relationshipToParent === "subagent"
? activeThread.lineage.parentThreadId
Expand Down
90 changes: 90 additions & 0 deletions apps/web/src/components/ChatView.waitingHold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
EnvironmentId,
MessageId,
RunId,
type OrchestrationV2RunStatus,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

import { deriveContinuableRunId, presentThreadShellFromProjection } from "./ChatView.logic";
import { makeThreadProjectionFixture } from "../test-fixtures";

const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z");

function run(status: OrchestrationV2RunStatus, base: OrchestrationV2ThreadProjection) {
return {
id: RunId.make("run-1"),
threadId: base.thread.id,
ordinal: 1,
providerInstanceId: base.thread.providerInstanceId,
modelSelection: base.thread.modelSelection,
providerThreadId: null,
userMessageId: MessageId.make("message-run-1"),
rootNodeId: null,
activeAttemptId: null,
status,
requestedAt: now,
startedAt: now,
completedAt: now,
checkpointId: null,
contextHandoffId: null,
};
}

const watchedPullRequest = {
host: "github.com",
repository: "acme/app",
number: 1,
url: "https://github.com/acme/app/pull/1",
source: "agent" as const,
snapshot: null,
watch: {
startedAt: "2026-01-01T00:00:00.000Z",
headSha: null,
failedChecks: [],
passed: false,
passedChecks: [],
remarksThrough: "2026-01-01T00:00:00.000Z",
remarkIds: [],
conflicting: false,
wakes: 0,
},
};

function projection(status: OrchestrationV2RunStatus, watched: boolean) {
const base = makeThreadProjectionFixture();
return {
...base,
thread: { ...base.thread, ...(watched ? { pullRequests: [watchedPullRequest] } : {}) },
runs: [run(status, base)],
} as unknown as Parameters<typeof presentThreadShellFromProjection>[2];
}

describe("pull request watch holds", () => {
it("gives a direct-link shell the watch roster its runtime parks on", () => {
const shell = presentThreadShellFromProjection(
EnvironmentId.make("environment-test"),
makeThreadProjectionFixture().thread.id,
projection("completed", true),
{ updatedAt: now, lastVisitedAt: null },
);
expect(shell.runtime?.status).toBe("idle");
expect(shell.pendingBackgroundTasks).toEqual([
expect.objectContaining({
taskId: "pull-request-watch:github.com/acme/app#1",
kind: "monitor",
awaited: true,
}),
]);
});

it("offers Continue for an interrupted run only when nothing holds the thread", () => {
const held = projection("interrupted", true);
expect(deriveContinuableRunId(held, null)).toBeNull();

const unheld = projection("interrupted", false);
expect(deriveContinuableRunId(unheld, null)).toBe(RunId.make("run-1"));
});
});
24 changes: 24 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,30 @@ describe("resolveSidebarThreadStatus", () => {
).toBe("approval");
});

it("keeps a completed thread Waiting, not Done, while a pull request watch holds it", () => {
const completed = { ...runtime, status: "completed" as const };
const watch = {
taskId: "pull-request-watch:github.com/acme/app#1",
description: "Watching pull request #1",
kind: "monitor" as const,
awaited: true as const,
};
expect(
resolveSidebarThreadStatus({ ...idle, runtime: completed, pendingBackgroundTasks: [watch] }),
).toBe("waiting");
expect(resolveSidebarThreadStatus({ ...idle, runtime: completed })).toBe("ready");
// Work the agent does not wait on, such as a dev server, does not hold it.
expect(
resolveSidebarThreadStatus({
...idle,
runtime: completed,
pendingBackgroundTasks: [
{ taskId: "dev-server", description: "Dev server", kind: "command" as const },
],
}),
).toBe("ready");
});

it("shows a waiting guest message as a Request, below the agent's own requests", () => {
expect(resolveSidebarThreadStatus({ ...idle, pendingGuestMessageCount: 1, runtime })).toBe(
"request",
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,9 @@ type SidebarThreadStatusInput = Pick<
| "pendingGuestMessageCount"
| "runtime"
| "blockedOnThreadWait"
>;
> & {
pendingBackgroundTasks?: SidebarThreadSummary["pendingBackgroundTasks"] | undefined;
};

export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): SidebarThreadStatus {
if (thread.hasPendingApprovals || thread.pendingSubagentRequest === "approval") {
Expand Down Expand Up @@ -887,6 +889,11 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si
if (thread.runtime?.status === "failed") {
return thread.runtime.lastErrorClass === "usage_limit" ? "limited" : "failed";
}
// Same hold the status pill reads: a PR watch or other awaited background
// work keeps the thread waiting even when its runtime was not parked.
if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) {
return "waiting";
}
return "ready";
}

Expand Down
Loading