From aa0b3e2da642a3a5284494f6e13fee30fb858aef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 22:02:03 -0700 Subject: [PATCH 001/107] perf(web): the pull request timeline mounts only the events near the viewport (#27795) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestDetailPanel.tsx | 10 +- .../PullRequestTimelineTab.browser.test.tsx | 89 ++++++++++++ .../pullRequest/PullRequestTimelineTab.tsx | 137 ++++++++++-------- .../pullRequest/pullRequestDetail.logic.ts | 2 +- 4 files changed, 172 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 3313bd86e459..ac12a3520e25 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -417,16 +417,16 @@ export function PullRequestDetailPanel({ Partial> >({}); const condensed = condensedByTab[tab] ?? false; - const scrollerByTab = useRef>>({}); + const scrollerByTab = useRef>>({}); // Fresh ref callbacks would detach and reattach every scroller on each render. const [scrollerRefs] = useState(() => ({ - summary: (node: HTMLDivElement | null) => { + summary: (node: HTMLElement | null) => { scrollerByTab.current.summary = node; }, - timeline: (node: HTMLDivElement | null) => { + timeline: (node: HTMLElement | null) => { scrollerByTab.current.timeline = node; }, - code: (node: HTMLDivElement | null) => { + code: (node: HTMLElement | null) => { scrollerByTab.current.code = node; }, })); @@ -436,7 +436,7 @@ export function PullRequestDetailPanel({ // so applying a delta afterward would compensate twice. const compensationRef = useRef<{ tab: PullRequestDetailTab; - scroller: HTMLDivElement; + scroller: HTMLElement; target: number; } | null>(null); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores the scroller's scrollTop before paint after the header condenses diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx new file mode 100644 index 000000000000..4e8f1e4319c5 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.browser.test.tsx @@ -0,0 +1,89 @@ +import { + EnvironmentId, + ProjectId, + SourceControlProviderKind, + type PullRequestDetailView, +} from "@t3tools/contracts"; +import { expect, it } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import "../../index.css"; +import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; + +const COMMITS = 2000; + +const detail: PullRequestDetailView = { + provider: SourceControlProviderKind.make("github"), + projectId: ProjectId.make("project"), + projectTitle: "Project", + workspaceRoot: "/workspace", + repository: "owner/repo", + number: 1, + title: "Huge pull request", + body: "", + url: "https://github.com/owner/repo/pull/1", + author: { login: "author", name: null, avatarUrl: null }, + viewer: "author", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 0, + changedFiles: 1, + headBranch: "feature", + baseBranch: "main", + createdAt: "2026-09-01T00:00:00Z", + updatedAt: "2026-09-01T00:00:00Z", + mergedAt: null, + closedAt: null, + reviewers: [], + labels: [], + checks: [], + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: Array.from({ length: COMMITS }, (_, index) => ({ + oid: `${index.toString(16).padStart(7, "0")}${"0".repeat(33)}`, + messageHeadline: `Commit number ${index}`, + committedDate: new Date(Date.UTC(2026, 8, 1, 0, index)).toISOString(), + })), + mergeCapabilities: { merge: false, squash: false, rebase: false }, + capabilities: { + diff: true, + comment: true, + search: true, + actions: [], + mergeMethods: [], + review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, + reviewers: { request: false, listCandidates: false }, + edit: { changeRequest: false, comment: false }, + }, + viewerPermissions: { + actions: [], + comment: false, + resolve: false, + verdicts: [], + requestReviewers: false, + }, +}; + +it("mounts only the timeline rows near the viewport of a huge pull request", async () => { + await using screen = await render( +
+ {}} + detail={detail} + environmentId={EnvironmentId.make("environment")} + reference={detail} + order="oldest" + onOpenCommit={() => {}} + onRefresh={() => {}} + /> +
, + ); + await expect.element(screen.getByText("Commit number 0")).toBeVisible(); + const mounted = screen.container.querySelectorAll("button").length; + expect(mounted).toBeGreaterThan(0); + expect(mounted).toBeLessThan(COMMITS / 10); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index b522094e90c9..1f41f2381148 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -34,6 +34,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { buildPullRequestTimeline, groupPullRequestTimelineConversations, + type PullRequestTimelineRow, isPullRequestVerdictStale, newestPullRequestCommitAt, pullRequestReviewOutcome, @@ -56,6 +57,7 @@ import { pullRequestReviewOutcomeToneClassName, } from "./pullRequestPresentation"; import { PullRequestGlyph } from "./pullRequestIcons"; +import { LegendList, type LegendListRef } from "@legendapp/list/react"; interface ReactionSurface { readonly canReact: boolean; @@ -634,7 +636,7 @@ export function PullRequestTimelineTab({ onOpenCommit, onRefresh, }: { - scrollerRef: (node: HTMLDivElement | null) => void; + scrollerRef: (node: HTMLElement | null) => void; detail: PullRequestDetailView; environmentId: EnvironmentId; threadRef?: ScopedThreadRef | null; @@ -666,66 +668,81 @@ export function PullRequestTimelineTab({ void readLocalApi()?.shell.openExternal(url); }; - return ( -
-
-
- - {rows.map((row) => { - if (row.kind === "comments") { - return ( - - ); - } - const event = row.event; - if (event.thread !== undefined) { - return ( - - ); - } - if (event.kind === "commit") { - return ; - } - const outcome = pullRequestReviewOutcome(event.reviewState); - if (outcome !== null) { - return ( - - ); - } - return ; - })} -
+ const rowKey = (row: PullRequestTimelineRow) => { + if (row.kind === "comments") return `comments:${row.events[0]?.id ?? "empty"}`; + const event = row.event; + // A thread's resolution or dismissal remounts it, so its disclosure starts in the new state. + return event.thread === undefined + ? event.id + : `thread:${event.id}:${event.thread.isResolved}:${pullRequestReviewOutcome(event.reviewState) === "dismissed"}`; + }; + const renderRow = ({ item: row }: { item: PullRequestTimelineRow }) => { + if (row.kind === "comments") { + return ( + + ); + } + const event = row.event; + if (event.thread !== undefined) { + return ( + + ); + } + if (event.kind === "commit") return ; + const outcome = pullRequestReviewOutcome(event.reviewState); + if (outcome !== null) { + return ( + + ); + } + return ; + }; - {events.length === 0 ? ( -
- -

No activity yet.

-
- ) : null} + if (events.length === 0) { + return ( +
+
+ +

No activity yet.

+
-
+ ); + } + + // Virtualized: a large pull request has thousands of events, and mounting every one stalls + // the page. The connector line is drawn by the content container, behind the rows. + return ( + + ref={(list: LegendListRef | null) => scrollerRef(list?.getScrollableNode() ?? null)} + data={rows} + extraData={{ editable, reactions, newestCommitAt }} + keyExtractor={rowKey} + renderItem={renderRow} + estimatedItemSize={140} + className="scrollbar-gutter-both h-full px-4" + contentContainerClassName="relative mx-auto w-full max-w-3xl before:absolute before:bottom-10 before:left-[15px] before:top-6 before:w-px before:bg-border/45" + ListHeaderComponent={
} + ListFooterComponent={
} + /> ); } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index d03f57bc44a3..f422dfbc0e3b 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -483,7 +483,7 @@ export interface PullRequestTimelineThread readonly replies: ReadonlyArray; } -type PullRequestTimelineRow = +export type PullRequestTimelineRow = | { readonly kind: "event"; readonly event: PullRequestTimelineEvent } | { readonly kind: "comments"; readonly events: ReadonlyArray }; From 11df0cbc4a3726ca8ac4017cf6918329b91ad879 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 22:10:01 -0700 Subject: [PATCH 002/107] perf(web): pull request comments reuse their markdown parse (#27808) Co-authored-by: Claude Opus 5.5 --- .../components/ChatMarkdown.browser.test.tsx | 15 +++++++++ apps/web/src/components/ChatMarkdown.tsx | 27 ++++++++++------ .../chat/TimelineWorkEntryRow.logic.ts | 4 +++ .../components/chat/TimelineWorkEntryRow.tsx | 14 ++++----- .../components/chat/TimelineWorkGroupRows.tsx | 7 +++-- .../pullRequest/PullRequestDetailPanel.tsx | 4 +-- .../PullRequestMarkdown.browser.test.tsx | 2 +- .../pullRequest/PullRequestMarkdown.tsx | 18 ++++------- .../pullRequest/pullRequestMarkdown.logic.ts | 31 ++++++++++++++++++- 9 files changed, 87 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index d5ea6a943c0c..e4dfceefd91d 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -165,6 +165,21 @@ it("reuses the parse of a streaming reply's finished blocks when it remounts", a expect(parsedSources.filter((source) => source.includes("Finished block"))).toHaveLength(1); }); +it("reuses the parse of a remounted message that has stable extra plugins", async () => { + setup(); + const parsed = vi.fn(); + const countingPlugins = [() => () => void parsed()]; + const message = ( + + ); + { + await using _first = await render(message); + } + await using screen = await render(message); + await expect.element(screen.getByText("Cached text")).toBeVisible(); + expect(parsed).toHaveBeenCalledTimes(1); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 93e182fb408b..9413b13a088c 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -241,6 +241,7 @@ interface ChatMarkdownProps { imageBaseDir?: string | undefined; workspaceMutationId?: string | null | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; + /** Keep one list per configuration (a module constant): it keys the shared parse cache. */ extraRemarkPlugins?: NonNullable; renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined; /** Loads media a source control host keeps behind a credential through the server, which holds @@ -1647,6 +1648,18 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { mathBreaks: [REMARK_MATH, ...CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS], } satisfies Record; +const combinedPlugins = new WeakMap>(); + +// One combined list per pair, so a stable extra list shares a renderer and its parse cache. +function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { + if (extra.length === 0) return shared; + let byShared = combinedPlugins.get(extra); + if (!byShared) combinedPlugins.set(extra, (byShared = new WeakMap())); + let combined = byShared.get(shared); + if (!combined) byShared.set(shared, (combined = [...shared, ...extra])); + return combined; +} + const markdownRenderers = new WeakMap< PluggableList, WeakMap> @@ -1735,15 +1748,11 @@ function ChatMarkdown({ ? "breaks" : "plain" ]; - // Extra and incremental plugins are per instance, so their output is not shared. - const uncachedPlugins = extraRemarkPlugins.length > 0 || incrementalParsing; - const remarkPlugins = uncachedPlugins - ? [ - ...sharedRemarkPlugins, - ...extraRemarkPlugins, - ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), - ] - : sharedRemarkPlugins; + // The incremental parser keeps per-instance state, so its output is not shared. + const uncachedPlugins = incrementalParsing; + const remarkPlugins = incrementalParsing + ? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()] + : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); const blocks = streamingBlocks ?? [text]; return ( diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts index 12b9a6f19734..4a067b7de1ce 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts @@ -1,3 +1,4 @@ +import type { PluggableList } from "unified"; import type { Root, RootContent } from "mdast"; import { resolveWorkEntryToolPresentation, @@ -237,3 +238,6 @@ export function remarkThoughtPreview(fallback: string) { : [{ type: "text", value: fallback }]; }; } + +export const THOUGHT_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thought"]]; +export const THINKING_PREVIEW_PLUGINS: PluggableList = [[remarkThoughtPreview, "Thinking"]]; diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index f485f62c9c39..76f2f604ba85 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -64,7 +64,8 @@ import { workEntryRawCommand, buildToolCallExpandedBody, workEntryIconName, - remarkThoughtPreview, + THINKING_PREVIEW_PLUGINS, + THOUGHT_PREVIEW_PLUGINS, } from "./TimelineWorkEntryRow.logic"; export function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) { @@ -421,12 +422,11 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { text={workEntry.detail ?? previewText} cwd={ctx.markdownCwd} parseRawHtml={false} - extraRemarkPlugins={[ - [ - remarkThoughtPreview, - workEntry.toolLifecycleStatus === "inProgress" ? "Thinking" : "Thought", - ], - ]} + extraRemarkPlugins={ + workEntry.toolLifecycleStatus === "inProgress" + ? THINKING_PREVIEW_PLUGINS + : THOUGHT_PREVIEW_PLUGINS + } /> ) : threadLabel ? ( } @@ -558,7 +559,7 @@ export function LiveWorkEntryTimelineRow({ text={row.entry.detail ?? label} cwd={ctx.markdownCwd} parseRawHtml={false} - extraRemarkPlugins={[[remarkThoughtPreview, row.active ? "Thinking" : "Thought"]]} + extraRemarkPlugins={row.active ? THINKING_PREVIEW_PLUGINS : THOUGHT_PREVIEW_PLUGINS} /> ) : threadLabel ? ( ReadonlyArray) | null; + /** Stable per repository, so comments share one renderer and its parse cache. */ + remarkPlugins: PluggableList; lineBreaks: boolean; threadRef: ScopedThreadRef | null; } | null>(null); @@ -72,11 +70,7 @@ export function PullRequestMarkdown({ }) { const segments = splitPullRequestBody(text); const context = useContext(PullRequestMarkdownContext); - const autolinks = context?.autolinks; const resolvedThreadRef = threadRef ?? context?.threadRef ?? undefined; - const extraRemarkPlugins: NonNullable = autolinks - ? [[remarkPullRequestAutolinks, { autolinks }]] - : []; return (
diff --git a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts index bb38f3d6caf9..2cace6166668 100644 --- a/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestMarkdown.logic.ts @@ -1,4 +1,9 @@ -import type { ReferenceAutolink } from "@t3tools/client-runtime/source-control-clients"; +import { changeRequestReferenceAutolinker } from "@t3tools/client-runtime/pull-request-markdown"; +import { + sourceControlClients, + type ReferenceAutolink, +} from "@t3tools/client-runtime/source-control-clients"; +import type { PluggableList } from "unified"; export { splitPullRequestBody, @@ -52,3 +57,27 @@ export function remarkPullRequestAutolinks(options: { }; return (tree: MarkdownNode) => visit(tree); } + +const NO_AUTOLINK_PLUGINS: PluggableList = []; +const autolinkPlugins = new Map(); + +/** + * One plugin list per repository, so every comment of a pull request shares one markdown + * renderer and its parse cache instead of parsing again on each render. + */ +export function pullRequestAutolinkPlugins( + changeRequest: { readonly provider: string; readonly url: string } | null, +): PluggableList { + if (changeRequest === null) return NO_AUTOLINK_PLUGINS; + const repositoryUrl = sourceControlClients.parseChangeRequestUrl(changeRequest.url)?.link + .repositoryUrl; + if (repositoryUrl === undefined) return NO_AUTOLINK_PLUGINS; + const key = `${changeRequest.provider}\u0000${repositoryUrl}`; + let plugins = autolinkPlugins.get(key); + if (plugins === undefined) { + const autolinks = changeRequestReferenceAutolinker(changeRequest); + plugins = autolinks ? [[remarkPullRequestAutolinks, { autolinks }]] : NO_AUTOLINK_PLUGINS; + autolinkPlugins.set(key, plugins); + } + return plugins; +} From 6d46336476941257d774be7c7733e9caf201fec2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 22:13:34 -0700 Subject: [PATCH 003/107] fix(observability): redact credential URL params in spans and type them as Redacted (#27986) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/EnvironmentAuth.test.ts | 28 +++++++++------- apps/server/src/auth/EnvironmentAuth.ts | 20 ++++++----- apps/server/src/auth/McpOAuth.ts | 19 +++++++---- apps/server/src/auth/mcpOAuthHttp.ts | 9 +++-- apps/server/src/peer/PeerLinks.ts | 7 ++-- .../components/auth/ConnectAgentSurface.tsx | 6 ++-- .../src/authorization/remote.ts | 7 ++-- .../src/state/deviceHubAccess.ts | 3 +- packages/contracts/src/auth.ts | 8 ++--- packages/shared/src/httpObservability.test.ts | 33 +++++++++++++++++++ packages/shared/src/httpObservability.ts | 25 +++++++++++--- 11 files changed, 120 insertions(+), 45 deletions(-) create mode 100644 packages/shared/src/httpObservability.test.ts diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index bd5d3b569958..6d080abbaf88 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -703,40 +703,44 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); const rpc = yield* serverAuth.issueWebSocketTicket(session, { use: "rpc" }); - const bare = yield* Effect.flip(upgrade(rpc.ticket, "rpc")); + const bare = yield* Effect.flip(upgrade(Redacted.value(rpc.ticket), "rpc")); expect(bare._tag).toBe("ServerAuthInvalidCredentialError"); const signed = yield* upgrade( - rpc.ticket, + Redacted.value(rpc.ticket), "rpc", - key.sign({ method: "GET", url: socketUrl, token: rpc.ticket, iat }), + key.sign({ method: "GET", url: socketUrl, token: Redacted.value(rpc.ticket), iat }), ); expect(signed.sessionId).toBe(session.sessionId); const wrongKey = yield* Effect.flip( upgrade( - rpc.ticket, + Redacted.value(rpc.ticket), "rpc", - other.sign({ method: "GET", url: socketUrl, token: rpc.ticket, iat }), + other.sign({ method: "GET", url: socketUrl, token: Redacted.value(rpc.ticket), iat }), ), ); expect(wrongKey._tag).toBe("ServerAuthInvalidCredentialError"); const onMedia = yield* Effect.flip( upgrade( - rpc.ticket, + Redacted.value(rpc.ticket), "media", - key.sign({ method: "GET", url: socketUrl, token: rpc.ticket, iat }), + key.sign({ method: "GET", url: socketUrl, token: Redacted.value(rpc.ticket), iat }), ), ); expect(onMedia._tag).toBe("ServerAuthInvalidCredentialError"); const media = yield* serverAuth.issueWebSocketTicket(session, { use: "media" }); - expect((yield* upgrade(media.ticket, "media")).sessionId).toBe(session.sessionId); - const mediaOnRpc = yield* Effect.flip(upgrade(media.ticket, "rpc")); + expect((yield* upgrade(Redacted.value(media.ticket), "media")).sessionId).toBe( + session.sessionId, + ); + const mediaOnRpc = yield* Effect.flip(upgrade(Redacted.value(media.ticket), "rpc")); expect(mediaOnRpc._tag).toBe("ServerAuthInvalidCredentialError"); // Clients that predate ticket binding still connect with the unbound ticket they mint. const legacy = yield* serverAuth.issueWebSocketTicket(session); - expect((yield* upgrade(legacy.ticket, "rpc")).sessionId).toBe(session.sessionId); + expect((yield* upgrade(Redacted.value(legacy.ticket), "rpc")).sessionId).toBe( + session.sessionId, + ); }).pipe( Effect.provide(layerEnvironmentAuth({ desktopBootstrapToken: "desktop-bootstrap-token" })), ), @@ -757,10 +761,10 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { const rpc = yield* serverAuth.issueWebSocketTicket(session, { use: "rpc" }); const request = { method: "GET", - url: `/ws?wsTicket=${rpc.ticket}&dpop=${key.sign({ + url: `/ws?wsTicket=${Redacted.value(rpc.ticket)}&dpop=${key.sign({ method: "GET", url: "http://192.168.1.50:3773/ws", - token: rpc.ticket, + token: Redacted.value(rpc.ticket), iat: Math.floor((yield* DateTime.now).epochMilliseconds / 1_000), })}`, cookies: {}, diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 7d73e6592522..6f7797573424 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -31,6 +31,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; @@ -568,8 +569,11 @@ type BootstrapExchangeResult = { const AUTHORIZATION_PREFIX = "Bearer "; const DPOP_AUTHORIZATION_PREFIX = "DPoP "; -const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; -const WEBSOCKET_PROOF_QUERY_PARAM = "dpop"; +const WebSocketUpgradeQuery = Schema.Struct({ + wsTicket: Schema.optionalKey(Schema.RedactedFromValue(Schema.String)), + dpop: Schema.optionalKey(Schema.RedactedFromValue(Schema.String)), +}); +const decodeWebSocketUpgradeQuery = Schema.decodeEffect(WebSocketUpgradeQuery); const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { const leftCanManage = left.scopes.includes(AuthAccessWriteScope); @@ -1215,7 +1219,7 @@ export const make = Effect.gen(function* () { Effect.map( (issued) => ({ - ticket: issued.token, + ticket: Redacted.make(issued.token), expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies AuthWebSocketTicketResult, ), @@ -1231,7 +1235,10 @@ export const make = Effect.gen(function* () { Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request, use) { const requestUrl = HttpServerRequest.toURL(request); if (Option.isSome(requestUrl)) { - const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); + const query = yield* decodeWebSocketUpgradeQuery( + Object.fromEntries(requestUrl.value.searchParams), + ).pipe(Effect.orDie); + const websocketTicket = query.wsTicket && Redacted.value(query.wsTicket); if (websocketTicket && websocketTicket.trim().length > 0) { const session = yield* sessions .verifyWebSocketToken(websocketTicket) @@ -1245,10 +1252,7 @@ export const make = Effect.gen(function* () { // WebSocket clients cannot set headers, so the proof travels in the query. // The proof hashes the ticket; consuming its jti prevents replay after first use. // Only encrypted transport prevents an active interceptor from racing that use. - const proof = - request.headers.dpop ?? - requestUrl.value.searchParams.get(WEBSOCKET_PROOF_QUERY_PARAM) ?? - undefined; + const proof = request.headers.dpop ?? (query.dpop && Redacted.value(query.dpop)); if (!proof) { return yield* new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound WebSocket ticket requires a DPoP proof.", diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 10aa0e430d74..7416f391ed66 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -23,6 +23,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { HttpServerRequest } from "effect/http"; @@ -162,7 +163,7 @@ export class McpOAuthRedirectError extends Schema.TaggedError | undefined; readonly resource: string; readonly issuer: string; } @@ -220,10 +221,15 @@ export class McpOAuth extends Context.Service< } >()("t3/auth/McpOAuth") {} -const redirectWith = (redirectUri: string, params: Record) => { +const redirectWith = ( + redirectUri: string, + params: Record | undefined>, +) => { const url = new URL(redirectUri); for (const [name, value] of Object.entries(params)) { - if (value !== undefined) url.searchParams.set(name, value); + if (value !== undefined) { + url.searchParams.set(name, Redacted.isRedacted(value) ? Redacted.value(value) : value); + } } return url.toString(); }; @@ -484,8 +490,9 @@ const make = Effect.gen(function* () { if (token.grant_type !== "authorization_code") { return yield* fail("unsupported_grant_type", "Only authorization_code is supported."); } - const { code, redirect_uri: redirectUri, client_id: clientId } = token; - const verifier = token.code_verifier; + const { redirect_uri: redirectUri, client_id: clientId } = token; + const code = token.code && Redacted.value(token.code); + const verifier = token.code_verifier && Redacted.value(token.code_verifier); if (!code || !redirectUri || !clientId || !verifier) { return yield* fail( "invalid_request", diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts index dfb272605bce..8a2c439d9bbb 100644 --- a/apps/server/src/auth/mcpOAuthHttp.ts +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -1,12 +1,13 @@ import { AuthMcpApprovalError, - type AuthMcpAuthorizationRequest, + AuthMcpAuthorizationRequest, AuthMcpClientAccess, EnvironmentHttpApi, mcpClientScopes, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { HttpServerRequest, HttpServerResponse } from "effect/http"; import * as HttpEffect from "effect/http/HttpEffect"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; @@ -33,6 +34,7 @@ const noStore = HttpEffect.appendPreResponseHandler((_request, response) => ); const requestUrls = Effect.map(HttpServerRequest.HttpServerRequest, McpOAuth.requestUrls); +const decodeAuthorizationRequest = Schema.decodeEffect(AuthMcpAuthorizationRequest); /** * An unverified client or redirect is reported, never redirected; any other problem goes back to @@ -86,8 +88,11 @@ export const layer = HttpApiBuilder.group( Effect.gen(function* () { const urls = yield* requestUrls; const url = new URL(request.url, urls.issuer); + const authorizationRequest = yield* decodeAuthorizationRequest( + Object.fromEntries(url.searchParams), + ).pipe(Effect.orDie); const authorization = yield* oauth - .validateAuthorization({ urls, request: Object.fromEntries(url.searchParams) }) + .validateAuthorization({ urls, request: authorizationRequest }) .pipe(Effect.result); const headers = { "cache-control": "no-store", "referrer-policy": "no-referrer" }; if (Result.isSuccess(authorization)) { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index f47ea7d006a3..11d058744084 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -21,6 +21,7 @@ import * as Base64Url from "effect/encoding/Base64Url"; import * as Hex from "effect/encoding/Hex"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; @@ -263,7 +264,7 @@ const make = Effect.gen(function* () { redirect_uri: LINK_REDIRECT_URI, code_challenge: challenge, code_challenge_method: "S256", - state, + state: Redacted.make(state), resource, }, decision: { _tag: "pairing-code", access: input.access, code: input.pairingCode }, @@ -297,10 +298,10 @@ const make = Effect.gen(function* () { .token({ payload: { grant_type: "authorization_code", - code, + code: Redacted.make(code), redirect_uri: LINK_REDIRECT_URI, client_id: registered.client_id, - code_verifier: verifier, + code_verifier: Redacted.make(verifier), resource, }, }) diff --git a/apps/web/src/components/auth/ConnectAgentSurface.tsx b/apps/web/src/components/auth/ConnectAgentSurface.tsx index fae971b37316..fab38ccefad9 100644 --- a/apps/web/src/components/auth/ConnectAgentSurface.tsx +++ b/apps/web/src/components/auth/ConnectAgentSurface.tsx @@ -3,7 +3,7 @@ import { type AuthMcpApprovalDetails, AuthMcpApprovalError, AuthMcpClientAccess, - type AuthMcpAuthorizationRequest, + AuthMcpAuthorizationRequest, } from "@t3tools/contracts"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; import { isLoopbackHost } from "@t3tools/shared/preview"; @@ -76,8 +76,10 @@ function runApproval( ).catch((): Answer => ({ kind: "error", message: UNREACHABLE })); } +const decodeAuthorizationRequest = Schema.decodeSync(AuthMcpAuthorizationRequest); + function readAuthorizationRequest(): AuthMcpAuthorizationRequest { - return Object.fromEntries(new URL(window.location.href).searchParams); + return decodeAuthorizationRequest(Object.fromEntries(new URL(window.location.href).searchParams)); } /** Sessions can grant only scopes they hold. */ diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 24a254e785ba..135c443df3f4 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -12,6 +12,7 @@ import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import { hashTelemetryId } from "@t3tools/shared/telemetryId"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; import { @@ -290,7 +291,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( }); const url = rpcSocketUrl(input.wsBaseUrl); - url.searchParams.set("wsTicket", issued.ticket); + url.searchParams.set("wsTicket", Redacted.value(issued.ticket)); appendClientConnectionParams( url, input.clientMetadata, @@ -343,9 +344,9 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( const upgradeProof = yield* input.createProof({ method: "GET", url: `${url.protocol === "wss:" ? "https:" : "http:"}//${url.host}${url.pathname}`, - accessToken: issued.ticket, + accessToken: Redacted.value(issued.ticket), }); - url.searchParams.set("wsTicket", issued.ticket); + url.searchParams.set("wsTicket", Redacted.value(issued.ticket)); url.searchParams.set("dpop", upgradeProof); appendClientConnectionParams( url, diff --git a/packages/client-runtime/src/state/deviceHubAccess.ts b/packages/client-runtime/src/state/deviceHubAccess.ts index d3cedb1ab3c4..61e69881995e 100644 --- a/packages/client-runtime/src/state/deviceHubAccess.ts +++ b/packages/client-runtime/src/state/deviceHubAccess.ts @@ -5,6 +5,7 @@ * stream. */ import * as Effect from "effect/Effect"; +import * as Redacted from "effect/Redacted"; import type { HttpClient } from "effect/http"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; @@ -47,7 +48,7 @@ export const resolveDeviceHubAccess = Effect.fn("clientRuntime.state.resolveDevi return { httpBase, wsBase, - query: { wsTicket: ticket.ticket }, + query: { wsTicket: Redacted.value(ticket.ticket) }, credentials: false, }; }, diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 7e4cc84b7415..4037e6cc128b 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -238,7 +238,7 @@ export const AuthAccessTokenResult = Schema.Struct({ export type AuthAccessTokenResult = typeof AuthAccessTokenResult.Type; export const AuthWebSocketTicketResult = Schema.Struct({ - ticket: TrimmedNonEmptyString, + ticket: Schema.RedactedFromValue(TrimmedNonEmptyString), expiresAt: Schema.DateTimeUtc, }); export type AuthWebSocketTicketResult = typeof AuthWebSocketTicketResult.Type; @@ -490,7 +490,7 @@ export const AuthMcpAuthorizationRequest = Schema.Struct({ redirect_uri: Schema.optionalKey(Schema.String), code_challenge: Schema.optionalKey(Schema.String), code_challenge_method: Schema.optionalKey(Schema.String), - state: Schema.optionalKey(Schema.String), + state: Schema.optionalKey(Schema.RedactedFromValue(Schema.String)), resource: Schema.optionalKey(Schema.String), }); export type AuthMcpAuthorizationRequest = typeof AuthMcpAuthorizationRequest.Type; @@ -539,10 +539,10 @@ export class AuthMcpApprovalError extends Schema.TaggedError { + it.effect("redacts credential query parameters on HTTP client spans", () => { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const client = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))), + ); + return Effect.gen(function* () { + yield* client.get("https://example.com/ws?wsTicket=secret&clientOs=linux&dpop=proof"); + const span = spans.find((candidate) => candidate.kind === "client")!; + expect(span.attributes.get("url.full")).toBe( + "https://example.com/ws?wsTicket=REDACTED&clientOs=linux&dpop=REDACTED", + ); + expect(span.attributes.get("url.query")).toBe( + "wsTicket=REDACTED&clientOs=linux&dpop=REDACTED", + ); + }).pipe(Effect.provide(HttpObservability.layer), Effect.withTracer(tracer)); + }); +}); diff --git a/packages/shared/src/httpObservability.ts b/packages/shared/src/httpObservability.ts index 0fb56cf50878..3613cef1d083 100644 --- a/packages/shared/src/httpObservability.ts +++ b/packages/shared/src/httpObservability.ts @@ -1,9 +1,26 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Headers from "effect/http/Headers"; +import * as UrlParams from "effect/http/UrlParams"; -export const layer = Layer.effect( - Headers.CurrentRedactedNames, - // `expo-session` carries the `eas login` session secret on Expo API requests. - Effect.map(Headers.CurrentRedactedNames, (names) => [...names, "dpop", "expo-session"]), +export const layer = Layer.mergeAll( + Layer.effect( + Headers.CurrentRedactedNames, + // `expo-session` carries the `eas login` session secret on Expo API requests. + Effect.map(Headers.CurrentRedactedNames, (names) => [...names, "dpop", "expo-session"]), + ), + Layer.effect( + UrlParams.CurrentRedactedNames, + // Socket tickets and proofs, pairing tokens, and OAuth codes travel in query strings. + Effect.map(UrlParams.CurrentRedactedNames, (names) => [ + ...names, + "wsTicket", + "dpop", + "token", + "pairingUrl", + "code", + "state", + "challenge", + ]), + ), ); From d6d24336095e2dc2c1a88a37c862844a9d2eaf43 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 31 Oct 2026 06:00:31 +0000 Subject: [PATCH 004/107] fix(web): back-to-back messages stack tight with joined corners (#27978) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../chat/MessagesTimeline.logic.test.ts | 2 + .../components/chat/MessagesTimeline.logic.ts | 12 +- .../chat/TimelineUserMessageRow.tsx | 147 ++++++++++++------ 3 files changed, 108 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index c9aec993bcd8..4c7eba620b5d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1450,10 +1450,12 @@ describe("deriveMessagesTimelineRows", () => { expect(byId.get("prompt")).not.toHaveProperty("groupedWithNext"); expect(byId.get("first-steer")).toMatchObject({ groupedWithNext: true, + groupedWithPrevious: false, continuesSteer: false, }); expect(byId.get("second-steer")).toMatchObject({ groupedWithNext: false, + groupedWithPrevious: true, continuesSteer: true, }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1f22bc1c1497..2fe811db4d18 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -597,6 +597,8 @@ type MessagesTimelineRowContent = revertTurnCount?: number | undefined; /** Another of your messages follows directly, so the two sit closer together. */ groupedWithNext?: boolean; + /** Another of your messages sits directly above, so the joined corners tighten. */ + groupedWithPrevious?: boolean; /** Set when the message above is a steer too, so the label shows once. */ continuesSteer?: boolean; } @@ -2146,12 +2148,11 @@ export function deriveMessagesTimelineRows(input: { if (!timelineRowIsOwnUserMessage(row)) return row; const previous = result[index - 1]; const groupedWithNext = timelineRowIsOwnUserMessage(result[index + 1]); + const groupedWithPrevious = timelineRowIsOwnUserMessage(previous); const continuesSteer = - timelineRowIsOwnUserMessage(previous) && - messageIsSteer(row.message) && - messageIsSteer(previous.message); - if (!groupedWithNext && !continuesSteer) return row; - return { ...row, groupedWithNext, continuesSteer }; + groupedWithPrevious && messageIsSteer(row.message) && messageIsSteer(previous.message); + if (!groupedWithNext && !groupedWithPrevious) return row; + return { ...row, groupedWithNext, groupedWithPrevious, continuesSteer }; }); } @@ -2508,6 +2509,7 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean a.assistantTurnDiffSummary === bm.assistantTurnDiffSummary && a.revertTurnCount === bm.revertTurnCount && a.groupedWithNext === bm.groupedWithNext && + a.groupedWithPrevious === bm.groupedWithPrevious && a.continuesSteer === bm.continuesSteer ); } diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 48086d40b4bf..e73f5d1d1e9a 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -74,6 +74,7 @@ import { import { chatMarkdownClipboardPayload } from "../../markdown-clipboard"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { cn } from "~/lib/utils"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; @@ -174,9 +175,11 @@ function UserFileCard({ function UserVideoAttachment({ file, standalone = false, + cornerClassName, }: { readonly file: ChatFileAttachment; readonly standalone?: boolean; + readonly cornerClassName?: string | undefined; }) { const ctx = use(TimelineRowCtx); // Row updates rebuild attachment objects, so the asset follows only the @@ -198,7 +201,7 @@ function UserVideoAttachment({ className={cn( "flex aspect-[4/3] w-full items-center justify-center bg-black px-2 py-3 text-center text-2xs text-white/70", standalone - ? cn(MEDIA_EDGE_CLASS_NAME, "rounded-2xl") + ? cn(MEDIA_EDGE_CLASS_NAME, "rounded-2xl", cornerClassName) : "rounded-lg border border-border/80", )} > @@ -233,7 +236,9 @@ function UserVideoAttachment({ /> ); return standalone ? ( -
{player}
+
+ {player} +
) : ( player ); @@ -402,6 +407,7 @@ function UserMessageProjectedStatus({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { onImageExpand, onFileOpen } = ctx; + const coarsePointer = useMediaQuery({ pointer: "coarse" }); const senderThreadId = row.message.senderThreadId; const resources = selectMessageImageResources(row.message.attachments); const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources); @@ -484,6 +490,11 @@ function UserTimelineRow({ row }: { row: Extract 1 && regularImages.every((image) => image.source?.kind !== "snap-shot"); + // Corners that touch the bubble or a neighbouring message tighten, like the bubble's. + const mediaCornerClassName = cn( + row.groupedWithPrevious && "rounded-tr-md", + (mediaAboveText || row.groupedWithNext) && "rounded-br-md", + ); // Files sent without words show as cards, not as a bubble around a name chip. const fileCardsOnly = !folded && @@ -642,6 +653,7 @@ function UserTimelineRow({ row }: { row: Extract ))} {userVideos.map((file) => ( - + ))}
) : null; + // Touch shows actions all the time, and narrow lanes have no room beside the + // bubble, so both keep the actions on their own line. + const floatActions = row.groupedWithNext === true && !coarsePointer; + const messageActions = ( +
+ {folded && senderThreadId ? ( + ctx.onOpenThread(senderThreadId)} tone="muted"> + Open thread + + ) : null} + + }> + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} + + +
+ {typeof revertTurnCount === "number" && ( + + )} + {copyText && ( + + )} + {promptLink !== null && ( + + )} +
+
+ ); return (
{userMessage.attribution === "t3code" ? ( @@ -769,7 +840,8 @@ function UserTimelineRow({ row }: { row: Extract ) : null} + {floatActions ? ( + + ) : null}
{!folded || expanded ? ( -
-
- {folded && senderThreadId ? ( - ctx.onOpenThread(senderThreadId)} tone="muted"> - Open thread - - ) : null} - - }> - {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} - - - {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} - - -
- {typeof revertTurnCount === "number" && ( - - )} - {copyText && ( - - )} - {promptLink !== null && ( - - )} -
-
+
+ {messageActions}
) : null}
); } +const ACTIONS_REVEAL_CLASS_NAME = + "items-center text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100"; + function UserMessageIntentMarker({ intent, }: { From 961026c6c98118a5017888a8cb6ed7078db140eb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 30 Oct 2026 23:08:21 -0700 Subject: [PATCH 005/107] feat(web): context meter fills up and shows tokens against the limit (#27892) Co-authored-by: Claude Opus 5.5 --- .../components/chat/ThreadDetailsPanel.tsx | 10 +- .../components/chat/ThreadDetailsUsage.tsx | 169 +++++++++++------- .../chat/threadDetailsItems.test.ts | 11 +- .../src/components/chat/threadDetailsItems.ts | 11 +- docs/user/usage.md | 9 +- 5 files changed, 127 insertions(+), 83 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index 35a954db9d43..b8d96a33ec5a 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -23,7 +23,11 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; import { ThreadServersPanel } from "./ThreadServersPanel"; -import { ThreadDetailsUsage, type ThreadDetailsUsageInput } from "./ThreadDetailsUsage"; +import { + ThreadDetailsContext, + ThreadDetailsUsage, + type ThreadDetailsUsageInput, +} from "./ThreadDetailsUsage"; import { ThreadDetailsItemsMenuItems } from "./ThreadDetailsItemsMenu"; import { resolveThreadDetailsVisibility, THREAD_DETAILS_ITEMS } from "./threadDetailsItems"; import { useClientSettings } from "../../hooks/useSettings"; @@ -254,6 +258,10 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { {visible.usage ? : null} + {visible.context && props.usage.contextWindow ? ( + + ) : null} + {/* Shown only when no section above has content, so an emptied card can be refilled. */}
= { @@ -35,16 +36,14 @@ export interface ThreadDetailsUsageInput { readonly contextWindow: ContextWindowSnapshot | null; } -/** - * The selected provider's limit windows and the thread's context window. - * Every row reads as room left, so a longer bar always means more headroom. - */ +type UsageTone = "error" | "warning" | null; + +/** The selected provider's limit windows. Each row reads as room left. */ export function ThreadDetailsUsage({ providerInstanceId, providers, sources, - contextWindow, -}: ThreadDetailsUsageInput) { +}: Omit) { const nowMinute = useNowMinute(); const now = Date.parse(`${nowMinute}:00Z`); const report = @@ -56,28 +55,83 @@ export function ThreadDetailsUsage({ const limits = report?.accounts.find((entry) => entry.instanceId === providerInstanceId)?.limits ?? providers.find((provider) => provider.instanceId === providerInstanceId)?.usageLimits; - const notice = limits ? limitsNotice(limits) : null; - if (!limits && !contextWindow) return null; + if (!limits) return null; + const notice = limitsNotice(limits); return (
{notice ? ( {notice}} /> - ) : null} - {limits && !notice - ? limits.windows.map((window) => ) - : null} - {contextWindow ? : null} + ) : ( + limits.windows.map((window) => ) + )} +
+
+ ); +} + +/** The thread's context window. The bar fills as context is used; the tick marks auto-compaction. */ +export function ThreadDetailsContext({ usage }: { readonly usage: ContextWindowSnapshot }) { + const maxTokens = usage.maxTokens != null && usage.maxTokens > 0 ? usage.maxTokens : null; + const used = + maxTokens !== null && usage.usedPercentage !== null + ? Math.round(Math.max(0, Math.min(100, usage.usedPercentage))) + : null; + const threshold = usage.autoCompactThreshold ?? null; + const tick = + maxTokens !== null && threshold !== null && threshold > 0 && threshold < maxTokens + ? (threshold / maxTokens) * 100 + : null; + const usedTokens = formatContextWindowTokens(usage.usedTokens); + return ( + +
+ = 90 ? "error" : used >= 80 ? "warning" : null} + tick={tick} + muted + value={ + maxTokens !== null + ? `${usedTokens} / ${formatContextWindowTokens(maxTokens)}` + : usedTokens + } + ariaValue={used !== null ? `${used}% used` : `${usedTokens} tokens used`} + tooltip={ + <> + + {used !== null ? `Context: ${used}% used` : `Context: ${usedTokens} tokens used`} + + {maxTokens !== null ? ( + + {usage.usedTokens.toLocaleString("en-US")} of {maxTokens.toLocaleString("en-US")}{" "} + tokens + + ) : null} + {threshold !== null && threshold > 0 ? ( + + {formatContextWindowCompactionMessage(null, threshold)} + + ) : null} + + } + />
); @@ -97,7 +151,10 @@ function LimitRow({ return ( @@ -116,76 +173,43 @@ function LimitRow({ ); } -function ContextRow({ - usage, - spaced, -}: { - readonly usage: ContextWindowSnapshot; - readonly spaced: boolean; -}) { - const maxTokens = usage.maxTokens ?? null; - const left = - maxTokens !== null && usage.usedPercentage !== null - ? Math.round(100 - Math.max(0, Math.min(100, usage.usedPercentage))) - : null; - const used = formatContextWindowTokens(usage.usedTokens); - return ( - - {left !== null ? Context: {left}% left : null} - - {maxTokens !== null - ? `${used} of ${formatContextWindowTokens(maxTokens)} tokens used` - : `${used} tokens used`} - - - } - value={left === null ? used : undefined} - /> - ); -} - /** One label, hairline, and value row; the whole row opens the details tooltip. */ function UsageRow({ label, - left, - tooltip, + fill, + tone, + tick = null, value, + ariaValue = value, + tooltip, muted = false, - className, }: { readonly label: string; - readonly left: number | null; + /** Bar fill from 0 to 100. Null hides the bar. */ + readonly fill: number | null; + readonly tone: UsageTone; + /** A marker on the bar, from 0 to 100. */ + readonly tick?: number | null; + readonly value: string; + readonly ariaValue?: string; readonly tooltip: ReactNode; - /** Replaces the percentage when there is none. */ - readonly value?: string | undefined; readonly muted?: boolean; - readonly className?: string | undefined; }) { - const tone = left === null ? null : left <= 10 ? "error" : left <= 20 ? "warning" : null; return ( } > {label} - {left !== null ? ( + {fill !== null ? ( - {left > 0 ? ( + {fill > 0 ? ( + ) : null} + {tick !== null ? ( + ) : null} @@ -206,17 +237,17 @@ function UsageRow({ className={cn( // Text-only rows span the bar column so they never widen the shared value column. "text-end font-medium tabular-nums", - left === null && "col-span-2", + fill === null && "col-span-2", tone === "error" ? "text-error-foreground" : tone === "warning" ? "text-warning-foreground" - : left === null + : fill === null ? "text-muted-foreground" : "text-foreground/80", )} > - {value ?? `${left}%`} + {value} {/* Rows sit 8px inside the card; clear its edge instead of overlapping it. */} diff --git a/apps/web/src/components/chat/threadDetailsItems.test.ts b/apps/web/src/components/chat/threadDetailsItems.test.ts index 0a0787061d28..6e3e746fd764 100644 --- a/apps/web/src/components/chat/threadDetailsItems.test.ts +++ b/apps/web/src/components/chat/threadDetailsItems.test.ts @@ -11,16 +11,17 @@ const servers = THREAD_DETAILS_ITEMS.find((item) => item.id === "servers")!; describe("thread details items", () => { it.each([ - { legacy: false, items: {}, usage: false }, - { legacy: true, items: {}, usage: true }, - { legacy: true, items: { usage: false }, usage: false }, - { legacy: false, items: { usage: true, retired: false }, usage: true }, - ])("resolves Usage from overrides and the retired Labs flag: %o", (row) => { + { legacy: false, items: {}, usage: false, context: false }, + { legacy: true, items: {}, usage: true, context: true }, + { legacy: true, items: { usage: false }, usage: false, context: true }, + { legacy: false, items: { usage: true, retired: false }, usage: true, context: false }, + ])("resolves Usage and Context from overrides and the retired Labs flag: %o", (row) => { const visibility = resolveThreadDetailsVisibility({ threadDetailsItems: row.items, threadDetailsUsageEnabled: row.legacy, }); expect(visibility.usage).toBe(row.usage); + expect(visibility.context).toBe(row.context); expect(visibility.servers).toBe(true); }); diff --git a/apps/web/src/components/chat/threadDetailsItems.ts b/apps/web/src/components/chat/threadDetailsItems.ts index 7502a4c0b1dc..9922b1b8a0b4 100644 --- a/apps/web/src/components/chat/threadDetailsItems.ts +++ b/apps/web/src/components/chat/threadDetailsItems.ts @@ -11,7 +11,8 @@ export const THREAD_DETAILS_ITEMS = [ { id: "servers", label: "Servers", defaultVisible: true }, { id: "automations", label: "Automations", defaultVisible: true }, { id: "lineage", label: "Lineage", defaultVisible: true }, - { id: "usage", label: "Usage", defaultVisible: false }, + { id: "usage", label: "Usage limits", defaultVisible: false }, + { id: "context", label: "Context window", defaultVisible: false }, ] as const; export type ThreadDetailsItemId = (typeof THREAD_DETAILS_ITEMS)[number]["id"]; @@ -23,9 +24,11 @@ type ThreadDetailsItemSettings = Pick< "threadDetailsItems" | "threadDetailsUsageEnabled" >; -// People who turned on the retired Labs toggle keep Usage until they change it here. +// People who turned on the retired Labs toggle keep Usage and Context until they change them here. function defaultVisible(item: ThreadDetailsItem, settings: ThreadDetailsItemSettings) { - return item.id === "usage" ? settings.threadDetailsUsageEnabled : item.defaultVisible; + return item.id === "usage" || item.id === "context" + ? settings.threadDetailsUsageEnabled + : item.defaultVisible; } export function resolveThreadDetailsVisibility( @@ -53,7 +56,7 @@ export function isDefaultThreadDetailsVisibility(visibility: ThreadDetailsVisibi return THREAD_DETAILS_ITEMS.every((item) => visibility[item.id] === item.defaultVisible); } -/** Also clears the retired Labs flag, so Usage returns to off. */ +/** Also clears the retired Labs flag, so Usage and Context return to off. */ export const RESET_THREAD_DETAILS_ITEMS = { threadDetailsItems: {}, threadDetailsUsageEnabled: false, diff --git a/docs/user/usage.md b/docs/user/usage.md index af1fc02352f8..447924a072ef 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -120,11 +120,12 @@ current model's limits without leaving the conversation. The result opens above closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage → Limits**. -To keep them in view on web and desktop, turn on **Usage** in **Settings → Appearance → Thread details**, or right-click the thread details card. +To keep them in view on web and desktop, turn on **Usage limits** in **Settings → Appearance → Thread details**, or right-click the thread details card. The bottom of the thread details card then shows how much is left in each limit window of the -current provider account and in the thread's context window. Some windows, such as a weekly Opus -limit, count only one model. Hover a row to see when it resets, your pace, or -the token count. +current provider account. Some windows, such as a weekly Opus limit, count only one model. Hover a +row to see when it resets and your pace. Turn on **Context window** to also show the thread's +context use against the model's limit. A tick on the bar marks where the provider compacts +automatically, when it reports that point. OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in the environment. T3 cannot report limits for external OpenCode servers because their credentials From bf8e1324391a570fbca298cfd0c97d39381666a3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 30 Oct 2026 23:22:58 -0700 Subject: [PATCH 006/107] fix(server): preview discovery stops sending HTTP and TLS to agent-started ports (#27893) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 210 +++++++++++++----- apps/server/src/preview/PortScanner.ts | 111 +++------ .../components/chat/ThreadServersPanel.tsx | 2 +- .../components/preview/PreviewEmptyState.tsx | 2 +- docs/user/remote-access.md | 13 ++ docs/user/thread-sidebar.md | 7 +- 6 files changed, 209 insertions(+), 136 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 903e9a701009..3dee90313a2b 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -62,6 +62,17 @@ const layerProbeFailure = ( const LSOF_TEST_PORT = 43_123; +const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, +}); + const ownedScanner = (processIds: ReadonlyArray = [1234]) => Effect.tap(PortScanner.PortDiscovery, (scanner) => scanner.registerTerminalProcesses({ @@ -174,10 +185,11 @@ effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites" effectIt.effect("projects configured paths independently for simultaneous subscribers", () => { const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`; + let ready = true; const fetchFn = ((input: Parameters[0]) => { const url = String(input); return Promise.resolve( - url === docsUrl || url === adminUrl + ready && (url === docsUrl || url === adminUrl) ? new Response("app", { headers: { "content-type": "text/html" } }) : new Response("not found", { status: 404 }), ); @@ -185,7 +197,7 @@ effectIt.effect("projects configured paths independently for simultaneous subscr const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); return Effect.gen(function* () { - const scanner = yield* PortScanner.PortDiscovery; + const scanner = yield* ownedScanner(); const docsSnapshots: ReadonlyArray[] = []; const adminSnapshots: ReadonlyArray[] = []; yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) => @@ -198,6 +210,11 @@ effectIt.effect("projects configured paths independently for simultaneous subscr expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); + + ready = false; + yield* TestClock.adjust(Duration.seconds(15)); + expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl); + expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl); }).pipe(Effect.scoped, Effect.provide(layer)); }); @@ -289,28 +306,45 @@ effectIt.effect("uses the current configured fragment when readiness comes from }).pipe(Effect.provide(layer)); }); -effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => { - const redirects: Array = []; - const fetchFn = (async ( - input: Parameters[0], - init?: Parameters[1], - ) => { - redirects.push(init?.redirect); - if (String(input).startsWith("http:")) throw new TypeError("TLS listener"); - return new Response(null, { status: 302, headers: { location: "https://example.com" } }); - }) as typeof globalThis.fetch; - const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); +effectIt.effect( + "probes only a configured HTTPS URL, keeps it for an owned listener when the check fails, and does not follow redirects", + () => { + const configuredUrl = `https://localhost:${LSOF_TEST_PORT}/app`; + const requests: Array = []; + let respond = async () => + new Response(null, { status: 302, headers: { location: "https://example.com" } }); + const fetchFn = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + requests.push([String(input), init?.redirect]); + return respond(); + }) as typeof globalThis.fetch; + const layer = layerLsofScanner({ pid: () => 1234, fetch: fetchFn }); - return Effect.gen(function* () { - const scanner = yield* ownedScanner(); - const servers = yield* scanner.scan(); - expect(servers).toHaveLength(1); - expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`); - expect(redirects).toEqual(["manual", "manual"]); - }).pipe(Effect.provide(layer)); -}); + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + + respond = async () => new Response("unavailable", { status: 503 }); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); -effectIt.effect("probes a port only while T3 terminals own every listener on it", () => { + respond = () => Promise.reject(new TypeError("fetch failed")); + yield* TestClock.adjust(Duration.seconds(15)); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { pid: 1234, url: configuredUrl }, + ]); + expect(requests).toEqual(Array.from({ length: 3 }, () => [configuredUrl, "manual"])); + }).pipe(Effect.provide(layer)); + }, +); + +effectIt.effect("discovers owned listeners without sending HTTP or TLS probes", () => { const owned = `p1234\ncnode\nn[::1]:${LSOF_TEST_PORT}\n`; const unowned = `p5678\ncrpc\nn127.0.0.1:${LSOF_TEST_PORT}\n`; let stdout = unowned; @@ -330,19 +364,19 @@ effectIt.effect("probes a port only while T3 terminals own every listener on it" stdout = owned; expect(yield* scanner.scan()).toHaveLength(1); - expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]); + expect(requests).toEqual([]); yield* scanner.unregisterTerminal({ threadId: "scanner-thread", terminalId: "scanner-terminal", }); expect(yield* scanner.scan()).toHaveLength(0); - expect(requests).toHaveLength(1); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); effectIt.effect( - "excludes HTTP errors, non-navigation responses, and successful non-documents", + "excludes configured URLs answering with HTTP errors, non-navigation responses, or non-documents", () => { let pid = 1; let makeResponse = () => @@ -350,33 +384,35 @@ effectIt.effect( const fetchFn = ((_input: Parameters[0]) => Promise.resolve(makeResponse())) as typeof globalThis.fetch; const layer = layerLsofScanner({ pid: () => pid, fetch: fetchFn }); + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/`; return Effect.gen(function* () { - const scanner = yield* ownedScanner([1, 2, 3, 4, 5, 6, 7]); - expect(yield* scanner.scan()).toHaveLength(0); + const scanner = yield* PortScanner.PortDiscovery; + const scan = () => scanner.scan([configuredUrl]); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response("ready", { status: 200, headers: { "content-type": "text/plain" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 204, headers: { "content-type": "text/html" } }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => new Response(null, { status: 302 }); - expect(yield* scanner.scan()).toHaveLength(0); + expect(yield* scan()).toHaveLength(0); pid += 1; makeResponse = () => @@ -384,7 +420,7 @@ effectIt.effect( status: 200, headers: { "content-type": "application/xhtml+xml; charset=utf-8" }, }); - expect(yield* scanner.scan()).toHaveLength(1); + expect(yield* scan()).toHaveLength(1); }).pipe(Effect.provide(layer)); }, ); @@ -433,10 +469,11 @@ effectIt.effect( return target === undefined ? missing.readLink(path) : Effect.succeed(target); }, }); - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = layerProbeFailure( (input) => { lsofSpawns += 1; @@ -449,37 +486,45 @@ effectIt.effect( return Effect.gen(function* () { const scanner = yield* ownedScanner([4242]); const first = yield* scanner.scan(); - expect(first.map(({ port, pid, processName }) => ({ port, pid, processName }))).toEqual([ - { port: 8765, pid: 4242, processName: "python3" }, - ]); + expect( + first.map(({ port, pid, processName, url }) => ({ port, pid, processName, url })), + ).toEqual([{ port: 8765, pid: 4242, processName: "python3", url: "http://localhost:8765" }]); yield* scanner.scan(); expect(lsofSpawns).toBe(1); expect(fdWalks).toEqual(["77", "4242", "77", "4242"]); yield* scanner.scan(); yield* scanner.scan(); expect(fdWalks).toEqual(["77", "4242", "77", "4242", "77", "4242"]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }, ); effectIt.effect("ignores a partial Windows netstat snapshot when one query exits nonzero", () => { - const fetchFn = (() => - Promise.resolve( - new Response("hello", { headers: { "content-type": "text/html" } }), - )) as typeof globalThis.fetch; + let ipv6Fails = true; + const requests: string[] = []; + const fetchFn = ((input: Parameters[0]) => { + requests.push(String(input)); + return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); + }) as typeof globalThis.fetch; const layer = PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => { + if (input.command === "tasklist.exe") { + return Effect.succeed( + processOutput('"node.exe","1234","Console","1","50,000 K"\r\n'), + ); + } const isIpv6 = input.args.includes("tcpv6"); return Effect.succeed({ stdout: isIpv6 ? "" : " TCP 127.0.0.1:5173 0.0.0.0:0 LISTENING 1234\r\n", stderr: "", - code: isIpv6 ? ChildProcessSpawner.ExitCode(1) : null, + code: isIpv6 && ipv6Fails ? ChildProcessSpawner.ExitCode(1) : null, timedOut: false, stdoutTruncated: false, stderrTruncated: false, @@ -502,20 +547,14 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits processIds: [1234], }); expect(yield* scanner.scan()).toEqual([]); + ipv6Fails = false; + expect(yield* scanner.scan()).toMatchObject([ + { port: 5173, pid: 1234, processName: "node", url: "http://localhost:5173" }, + ]); + expect(requests).toEqual([]); }).pipe(Effect.provide(layer)); }); -const processOutput = (stdout: string): ProcessRunner.ProcessRunOutput => ({ - stdout, - stderr: "", - code: ChildProcessSpawner.ExitCode(0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, -}); - effectIt.effect("rechecks the socket owner before stopping a Linux server without lsof", () => { let holder = "4242"; const missing = FileSystem.makeNoop({}); @@ -569,6 +608,63 @@ effectIt.effect("rechecks the socket owner before stopping a Linux server withou }).pipe(Effect.provide(layer)); }); +effectIt.effect.each(["linux", "darwin", "win32"] as const)( + "reads no listeners on %s while T3CODE_PORT_DISCOVERY=0, and still probes configured URLs", + (platform) => { + const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`; + const commands: string[] = []; + const fileReads: string[] = []; + const requests: string[] = []; + const missing = FileSystem.makeNoop({}); + const layer = PortScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + FileSystem.layerNoop({ + readFileString: (path) => { + fileReads.push(path); + return missing.readFileString(path); + }, + readDirectory: (path) => { + fileReads.push(path); + return missing.readDirectory(path); + }, + }), + Layer.succeed(ProcessRunner.ProcessRunner, { + run: (input) => { + commands.push(input.command); + return Effect.succeed(processOutput(`p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`)); + }, + }), + Layer.succeed(HostProcess.Platform, platform), + Layer.succeed(HostProcess.Environment, { T3CODE_PORT_DISCOVERY: "0" }), + FetchHttpClient.layer.pipe( + Layer.provide( + Layer.succeed(FetchHttpClient.Fetch, (input) => { + requests.push(String(input)); + return Promise.resolve( + new Response("docs", { headers: { "content-type": "text/html" } }), + ); + }), + ), + ), + ), + ), + ); + + return Effect.gen(function* () { + const scanner = yield* ownedScanner(); + scanner.trackAgent(); + expect(yield* scanner.scan()).toEqual([]); + expect(yield* scanner.scan([configuredUrl])).toMatchObject([ + { port: LSOF_TEST_PORT, pid: null, terminal: null, url: configuredUrl }, + ]); + expect(requests).toEqual([configuredUrl]); + expect(commands).toEqual([]); + expect(fileReads).toEqual([]); + }).pipe(Effect.provide(layer)); + }, +); + effectIt.effect.each(["linux", "darwin"] as const)( "attributes listeners to the agent thread in their environment on %s, after the agent exits", (platform) => { @@ -672,7 +768,7 @@ effectIt.effect.each(["linux", "darwin"] as const)( expect(yield* scanner.scan()).toMatchObject([ { port: agentPort, pid: 4242, terminal: null, agent: { threadId: "thread-agent" } }, ]); - expect(requests).toEqual([`http://localhost:${agentPort}/`]); + expect(requests).toEqual([]); expect(yield* scanner.scan()).toHaveLength(1); expect(environmentReads).toBe(2); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 1080624d7508..e026b204c03d 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -68,6 +68,9 @@ export class PortDiscovery extends Context.Service< */ export const AGENT_THREAD_ENV = "T3CODE_THREAD_ID"; +/** `0` turns off listener discovery; configured preview URLs are still probed. */ +const PORT_DISCOVERY_ENV = "T3CODE_PORT_DISCOVERY"; + const POLL_INTERVAL = Duration.seconds(3); const LSOF_TIMEOUT_MS = 5_000; const PROC_FD_WALK_LIMIT = 50_000; @@ -132,8 +135,8 @@ interface WebProbeCacheEntry { interface WebProbeGroup { readonly server: DiscoveredLocalServer; - readonly urls: ReadonlyArray; - readonly configuredKey: string | null; + readonly url: string; + readonly configuredKey: string; } interface WebProbeSnapshot { @@ -189,17 +192,22 @@ const projectWebProbeSnapshot = ( configuredUrls: ReadonlyArray, ): ReadonlyArray => { const visibleByServer = new Map(); + const firstConfiguredByServer = new Map(); for (const raw of normalizeConfiguredUrls(configuredUrls)) { const url = new URL(raw); const port = urlPort(url); const serverKey = localServerKey(url.hostname, port); + if (!firstConfiguredByServer.has(serverKey)) firstConfiguredByServer.set(serverKey, raw); if (visibleByServer.has(serverKey)) continue; const configured = snapshot.configured.get(webProbeCacheKey(raw)); if (configured) visibleByServer.set(serverKey, { ...configured, url: raw }); } + // An owned listener whose configured check failed still opens at this caller's configured URL. for (const server of snapshot.discovered) { const key = localServerKey(server.host, server.port); - if (!visibleByServer.has(key)) visibleByServer.set(key, server); + if (visibleByServer.has(key)) continue; + const configuredUrl = firstConfiguredByServer.get(key); + visibleByServer.set(key, configuredUrl ? { ...server, url: configuredUrl } : server); } return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port); }; @@ -498,6 +506,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const processRunner = yield* ProcessRunner.ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcess.Platform; + const listenerDiscovery = (yield* HostProcess.Environment)[PORT_DISCOVERY_ENV] !== "0"; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); const stateRef = yield* Ref.make({ listeners: new Map(), @@ -715,24 +724,17 @@ export const make = Effect.gen(function* PortDiscoveryMake() { pid: null, terminal: null, }, - urls: [raw], + url: raw, configuredKey: resourceKey, }); } - for (const server of servers) { - // Never probe an unidentified listener with HTTP or TLS; binary RPC servers can crash on it. - if (!isOwned(server)) continue; - groups.push({ - server, - urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`], - configuredKey: null, - }); - } - return groups; }; + // Only configured URLs are requested. Owned listeners are listed as plain HTTP without a + // request: owning a process says nothing about its protocol, and HTTP or TLS bytes break + // binary ones such as Unity's test runner or Steam's IPC. const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* ( servers: ReadonlyArray, configuredUrls: ReadonlyArray, @@ -740,77 +742,37 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const nowMillis = yield* Clock.currentTimeMillis; const cached = yield* Ref.get(webProbeCacheRef); const groups = makeWebProbeGroups(servers, configuredUrls); - const batchProbes = new Map< - string, - Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }> - >(); - const batchProbeSemaphore = yield* Semaphore.make(1); - const getProbe = (url: string, pid: number | null) => { - const key = webProbeCacheKey(url); - const identity = `${key}\u0000${pid ?? ""}`; - return batchProbeSemaphore - .withPermits(1)( - Effect.gen(function* () { - const existing = batchProbes.get(identity); - if (existing) return [existing] as const; - const cachedProbe = cached.get(key); - const cachedIsCurrent = - cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis; - const memoized = yield* Effect.cached( - cachedIsCurrent - ? Effect.succeed({ probe: cachedProbe, fresh: false }) - : probeWebUrl(url).pipe( - Effect.map((result) => ({ - probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, - fresh: true, - })), - ), - ); - batchProbes.set(identity, memoized); - return [memoized] as const; - }), - ) - .pipe(Effect.flatMap(([probe]) => probe)); - }; const probed = yield* Effect.forEach( groups, - (group) => - Effect.gen(function* () { - const probes: Array = []; - let visibleUrl: string | null = null; - for (const url of group.urls) { - const key = webProbeCacheKey(url); - const { probe, fresh } = yield* getProbe(url, group.server.pid); - probes.push([key, probe, fresh]); - if (probe.isWeb) { - visibleUrl = url; - break; - } - } - return { group, probes, visibleUrl }; - }), + (group) => { + const { pid } = group.server; + const cachedProbe = cached.get(group.configuredKey); + return cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis + ? Effect.succeed({ group, probe: cachedProbe, fresh: false }) + : probeWebUrl(group.url).pipe( + Effect.map((result) => ({ + group, + probe: { pid, isWeb: result !== null, expiresAtMillis: 0 }, + fresh: true, + })), + ); + }, { concurrency: WEB_PROBE_CONCURRENCY }, ); const completedAtMillis = yield* Clock.currentTimeMillis; const nextCache = new Map( [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis), ); - const discovered: DiscoveredLocalServer[] = []; const configured = new Map(); - for (const { group, probes, visibleUrl } of probed) { - for (const [key, probe, fresh] of probes) { - nextCache.set( - key, - fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, - ); - } - if (visibleUrl === null) continue; - const server = { ...group.server, url: visibleUrl }; - if (group.configuredKey === null) discovered.push(server); - else configured.set(group.configuredKey, server); + for (const { group, probe, fresh } of probed) { + nextCache.set( + group.configuredKey, + fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe, + ); + if (probe.isWeb) configured.set(group.configuredKey, { ...group.server, url: group.url }); } yield* Ref.set(webProbeCacheRef, nextCache); - return { discovered, configured } satisfies WebProbeSnapshot; + return { discovered: servers.filter(isOwned), configured } satisfies WebProbeSnapshot; }); // tasklist costs 200-300ms per PID; query visible servers with bounded concurrency and cache names. @@ -873,6 +835,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* ( configuredUrls: ReadonlyArray, ) { + if (!listenerDiscovery) return yield* probeWebServers([], configuredUrls); const state = yield* Ref.get(stateRef); const terminalOwners = new Map(); for (const registration of state.terminalProcesses.values()) { diff --git a/apps/web/src/components/chat/ThreadServersPanel.tsx b/apps/web/src/components/chat/ThreadServersPanel.tsx index 353f6032b6f8..f1c8e1a1808f 100644 --- a/apps/web/src/components/chat/ThreadServersPanel.tsx +++ b/apps/web/src/components/chat/ThreadServersPanel.tsx @@ -30,7 +30,7 @@ import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./threadDetailsPanelStyles"; const serverKey = (server: DiscoveredLocalServer) => `${server.pid}:${server.port}`; -/** Web servers the thread's terminals and agent started. Hidden while none run. */ +/** Local servers the thread's terminals and agent started. Hidden while none run. */ export function ThreadServersPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; diff --git a/apps/web/src/components/preview/PreviewEmptyState.tsx b/apps/web/src/components/preview/PreviewEmptyState.tsx index 615d7e22586f..fc70ff9ecb06 100644 --- a/apps/web/src/components/preview/PreviewEmptyState.tsx +++ b/apps/web/src/components/preview/PreviewEmptyState.tsx @@ -40,7 +40,7 @@ export function PreviewEmptyState({ No preview yet - Type a URL above, or run a dev script. Browser-ready localhost servers will show up here + Type a URL above, or run a dev script. Local servers this thread starts will show up here automatically. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index e78992b69f20..9c426b64b134 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -243,6 +243,19 @@ On a phone, tap the floating preview's corner dot to show its controls, then **Pop into separate window** to keep watching in picture-in-picture over other apps. +### Local servers + +The browser lists ports held by processes your terminals and agents started. +T3 Code reads which process holds each port and never connects to it, so a +listed port opens as `http://localhost:` and might not be a web page. For +an HTTPS server or a specific path, set the **Preview URL** on the project +script that starts it; T3 Code checks that address and opens it as written. + +To stop listing ports, set `T3CODE_PORT_DISCOVERY=0` in the environment that +starts the T3 Code server, such as +[`service.env`](./background-service.md#configure-the-service-environment) for +the background service, and restart it. Script preview URLs still appear. + ### Browser host setup macOS, Windows, and Linux desktops run the browser as is. Some Linux hosts need diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index fb5e12b08372..062fd749a179 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -270,9 +270,10 @@ Expand a tool call in the conversation to see its full command and output. Summaries shorten shell wrappers and can still describe the latest call after it finishes; the call's own result shows its status. -On web and desktop, the thread details card lists the web servers that the -thread's terminals or its Claude agent started, with their ports. A server the -agent started stays listed after the agent's turn ends. +On web and desktop, the thread details card lists the local servers that the +thread's terminals or its Claude agent started, with their ports. Not every one +serves a web page. A server the agent started stays listed after the agent's +turn ends. Select a server to open it in the browser panel. **Stop** ends the server's job and keeps the agent, the terminal, and T3 Code running. Stop needs terminal access to the environment and is not available on Windows. From b823dcf5c6dab2fe489bc7ac4d448436acc6fa39 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 30 Oct 2026 23:28:34 -0700 Subject: [PATCH 007/107] ci(release): release version bump no longer fails on the pre-commit hook (#26978) Co-authored-by: Claude Opus 5.5 --- .github/workflows/mobile-eas-production.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 507e013bf1c2..6877f5c831a0 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -215,7 +215,7 @@ jobs: git config user.name "${APP_SLUG}[bot]" git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" git add apps/mobile/app.config.ts - git commit \ + git commit --no-verify \ -m "chore(mobile): bump app version to $RELEASE_VERSION" \ -m "Co-authored-by: codex " git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d89b7c7abe7..c36d51bdadb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1388,7 +1388,7 @@ jobs: git config user.email "${{ steps.app_bot.outputs.email }}" git add apps/server/package.json apps/desktop/package.json apps/web/package.json packages/contracts/package.json pnpm-lock.yaml - git commit -m "chore(release): prepare $RELEASE_TAG" + git commit --no-verify -m "chore(release): prepare $RELEASE_TAG" git push origin HEAD:main announce_discord: From 50c1c5dc536493826fc66e220598f38b831d5bde Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 00:03:39 -0700 Subject: [PATCH 008/107] feat: side chats beside the main thread (#26901) Co-authored-by: cdxker Co-authored-by: Claude Opus 5.5 --- .../src/features/keyboard/CommandPalette.tsx | 7 +- .../features/threads/ThreadDetailScreen.tsx | 7 + .../features/threads/ThreadSideChatsRow.tsx | 66 ++ .../src/features/threads/threadListV2.ts | 7 +- apps/mobile/src/state/entities.ts | 4 + .../src/environment/ServerEnvironment.ts | 1 + .../CheckpointCaptureService.test.ts | 186 +++++ .../CheckpointCaptureService.ts | 5 +- .../CheckpointRestoreSafety.test.ts | 50 ++ .../CheckpointRestoreSafety.ts | 17 +- .../src/orchestration-v2/CheckpointService.ts | 8 +- .../orchestration-v2/CommandPolicy.test.ts | 22 + .../src/orchestration-v2/CommandPolicy.ts | 6 +- .../ContextHandoffDelivery.ts | 4 + .../ContextHandoffService.test.ts | 91 +++ .../orchestration-v2/ContextHandoffService.ts | 190 ++++- .../orchestration-v2/IdleCompaction.test.ts | 9 +- .../src/orchestration-v2/IdleCompaction.ts | 4 +- .../src/orchestration-v2/Orchestrator.ts | 401 ++++++++--- .../orchestration-v2/ProjectionStore.test.ts | 91 ++- .../src/orchestration-v2/ProjectionStore.ts | 43 +- .../ProviderTurnStartService.test.ts | 376 ++++++++-- .../ProviderTurnStartService.ts | 244 +++++-- .../orchestration-v2/RunExecutionService.ts | 33 +- .../src/orchestration-v2/ShellStream.test.ts | 32 +- .../src/orchestration-v2/ShellStream.ts | 23 +- .../SteeringCompletion.integration.test.ts | 79 ++ .../ThreadFork.execution.test.ts | 614 +++++++++++++++- .../ThreadForkService.test.ts | 63 ++ .../src/orchestration-v2/ThreadForkService.ts | 42 +- .../ThreadManagementService.ts | 10 +- .../src/orchestration-v2/ThreadSearch.test.ts | 15 +- .../src/orchestration-v2/ThreadSearch.ts | 6 +- .../src/orchestration-v2/ThreadStream.test.ts | 27 +- .../src/orchestration-v2/runtimeLayer.test.ts | 214 ++++++ .../src/pullRequest/linkedThreads.test.ts | 10 +- apps/server/src/pullRequest/linkedThreads.ts | 1 + apps/server/src/relay/AgentAwarenessRelay.ts | 24 +- apps/server/src/serverRuntimeStartup.ts | 4 +- apps/server/src/storageCleanup.test.ts | 12 +- apps/server/src/storageCleanup.ts | 5 +- apps/web/src/browser/openFileInPreview.ts | 35 +- apps/web/src/closedViewStore.test.ts | 12 + apps/web/src/closedViewStore.ts | 2 + .../components/BackgroundThreadAnnouncer.tsx | 6 +- apps/web/src/components/ChatView.tsx | 462 +++++++----- .../src/components/CommandPalette.logic.ts | 4 +- .../components/ReopenClosedViewShortcut.tsx | 5 + apps/web/src/components/RightPanelTabs.tsx | 102 ++- apps/web/src/components/Sidebar.logic.ts | 17 +- .../chat/AssistantSelectionToolbar.tsx | 9 +- apps/web/src/components/chat/ChatComposer.tsx | 42 +- .../chat/ComposerPendingUserInputPanel.tsx | 2 + .../src/components/chat/MessagesTimeline.tsx | 19 +- .../chat/RightPanelSurfaceContent.tsx | 14 + apps/web/src/components/chat/SideChatPane.tsx | 179 +++++ .../components/chat/ThreadActivityBanners.tsx | 7 +- .../src/components/chat/ThreadDetailsCard.tsx | 6 +- .../components/chat/ThreadDetailsPanel.tsx | 4 +- ...hreadRelationshipsControl.browser.test.tsx | 14 +- .../chat/ThreadRelationshipsControl.tsx | 675 +++++++++--------- .../src/components/chat/backgroundThread.ts | 5 +- .../chat/composerSlashCommandSearch.ts | 8 +- .../src/components/chat/threadDetailsItems.ts | 1 + .../src/components/chat/timelineViewport.ts | 6 +- .../chat/useChatShortcuts.browser.test.tsx | 57 +- .../src/components/chat/useChatShortcuts.ts | 36 +- .../components/chat/useComposerMenuItems.ts | 22 + .../src/components/chat/useComposerSend.ts | 64 +- .../src/components/chat/useComposerStash.ts | 7 +- .../chat/usePasteToFocusComposer.ts | 4 +- .../components/chat/useRightPanelActions.ts | 13 +- apps/web/src/components/chat/useSideChat.ts | 96 +++ .../components/chat/useThreadLifecycle.tsx | 2 + .../preview/openPreviewSession.test.ts | 54 +- apps/web/src/composer-logic.test.ts | 28 + apps/web/src/composer-logic.ts | 10 +- ...useRightPanelSurfaceClose.browser.test.tsx | 3 + .../src/hooks/useRightPanelSurfaceClose.ts | 27 +- apps/web/src/lib/desktopPasteAsText.ts | 9 +- apps/web/src/lib/sideChatPane.ts | 31 + apps/web/src/reopenClosedView.test.ts | 13 + apps/web/src/reopenClosedView.ts | 7 +- apps/web/src/rightPanelStore.test.ts | 18 + apps/web/src/rightPanelStore.ts | 47 +- .../thread-lineage-and-context-transfer.md | 2 + docs/user/thread-sidebar.md | 24 + evals/mcp-tool-use/cases/side-chats.ndjson | 2 + evals/mcp-tool-use/run.ts | 19 +- .../client-runtime/src/composerThreadItems.ts | 15 +- .../client-runtime/src/operations/commands.ts | 36 +- .../client-runtime/src/promptLinks.test.ts | 32 + packages/client-runtime/src/promptLinks.ts | 3 + packages/client-runtime/src/state/models.ts | 20 +- .../src/state/olderSettledThreads.ts | 3 +- .../src/state/threadCommands.ts | 10 + .../src/state/threadShell.test.ts | 95 +++ .../client-runtime/src/state/threadShell.ts | 116 ++- packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestrationV2.ts | 28 +- packages/shared/src/agentAwareness.test.ts | 40 +- packages/shared/src/agentAwareness.ts | 13 +- 102 files changed, 4710 insertions(+), 987 deletions(-) create mode 100644 apps/mobile/src/features/threads/ThreadSideChatsRow.tsx create mode 100644 apps/web/src/components/chat/SideChatPane.tsx create mode 100644 apps/web/src/components/chat/useSideChat.ts create mode 100644 apps/web/src/lib/sideChatPane.ts create mode 100644 evals/mcp-tool-use/cases/side-chats.ndjson diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index 88987819ddf0..cc9946dded8a 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -8,7 +8,7 @@ import { type EnvironmentThreadSearchMatch, type ShownOrder, } from "@t3tools/client-runtime/state/thread-search"; -import { THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; +import { isListedThreadLineage, THREAD_JUMP_KEYBINDING_COMMANDS } from "@t3tools/contracts"; import { foldSearchQuery, PALETTE_MESSAGE_LIMIT, @@ -422,10 +422,7 @@ export function CommandPalette(props: { }), })); const threadItems: CommandPaletteItem[] = threads - .filter( - (thread) => - thread.archivedAt === null && thread.lineage.relationshipToParent !== "subagent", - ) + .filter((thread) => thread.archivedAt === null && isListedThreadLineage(thread.lineage)) .map((thread) => { const project = projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)); const environment = diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index eab4d4927660..870ab31f0000 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -195,6 +195,7 @@ import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import { ThreadGuestShareBar } from "./ThreadGuestShareBar"; +import { ThreadSideChatsRow } from "./ThreadSideChatsRow"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; @@ -1531,6 +1532,12 @@ function ThreadDetailBody(props: ThreadDetailScreenProps) { threadRef={props.selectedThreadDetailRef} canOperateThread={props.canOperateThread} /> + {props.draftMode ? null : ( + + )} {props.feedbackSubmissions.map((submission) => ( + + Side chats + + + {sideChats.map((sideChat) => { + const needs = + sideChat.hasPendingApprovals || sideChat.pendingSubagentRequest === "approval" + ? "approval" + : sideChat.hasPendingUserInput || sideChat.pendingSubagentRequest + ? "input" + : null; + return ( + + openLinkedThread({ environmentId: props.environmentId, threadId: sideChat.id }) + } + > + {needs === null ? null : ( + + )} + + {sideChat.title} + + + ); + })} + + + + ); +} diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ad02dd1192d6..61280bad84ef 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -28,7 +28,7 @@ import { sortPinnedThreadsByOrderKey, sortSettledThreads, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { isListedThreadLineage, type EnvironmentId, type ProjectId } from "@t3tools/contracts"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; import type { ThreadListProvider } from "../../state/thread-list-environments"; @@ -265,8 +265,7 @@ export function getThreadListV2OrderedSection(input: { }): EnvironmentThreadShell[] { const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined; const threads = input.threads.filter((thread) => { - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - return false; + if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) return false; if ( (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && thread.settledOverride === "settled" && @@ -647,7 +646,7 @@ export function buildThreadListV2Items(input: { const listedThreads = input.threads.filter( (thread) => thread.archivedAt === null && - thread.lineage.relationshipToParent !== "subagent" && + isListedThreadLineage(thread.lineage) && (input.environmentId === null || thread.environmentId === input.environmentId) && (projectKeys === null || projectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index a376938ffb41..c5c33a5432e2 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -198,6 +198,10 @@ export function useChildThreadInputs(ref: ScopedThreadRef) { return useAtomValue(environmentThreadShells.childThreadInputsAtom(ref)); } +export function useSideChats(ref: ScopedThreadRef) { + return useAtomValue(environmentThreadShells.sideChatsAtom(ref)); +} + export function useLiveThreadLinkLabels(markdown: string, environmentId: EnvironmentId): string { const titles = useAtomValue( hasThreadLinks(markdown) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b36fa64dc22a..c8bf34451309 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -224,6 +224,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, pullRequestChecks: true, + sideChats: true, inlineMessageContext: true, requiredWorktreeBootstrap: true, threadSettlement: true, diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index ff19634917f3..17fe7224773d 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -719,4 +719,190 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { assert.deepEqual([...projected.readyCheckpointOrdinals].toSorted(), [0, 1]); }), ); + + it.effect("completes a side chat run with a missing checkpoint and never touches git", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const sideChatThreadId = ThreadId.make("thread:checkpoint-capture-side-chat"); + const sideChatRunId = RunId.make("run:checkpoint-capture-side-chat"); + const sideChatScopeId = CheckpointScopeId.make("scope:checkpoint-capture-side-chat"); + const sideChatRootNodeId = NodeId.make("node:checkpoint-capture-side-chat-root"); + const sideChatProviderThreadId = ProviderThreadId.make( + "provider-thread:checkpoint-capture-side-chat", + ); + const event = { threadId: sideChatThreadId, occurredAt: now }; + const events: ReadonlyArray = [ + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:thread"), + type: "thread.created", + payload: { + createdBy: "user", + creationSource: "web", + id: sideChatThreadId, + projectId, + title: "Side chat", + providerInstanceId, + modelSelection, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: "/repo", + activeProviderThreadId: null, + lineage: { + parentThreadId: threadId, + relationshipToParent: "fork", + rootThreadId: threadId, + sideChat: true, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:provider-thread"), + type: "provider-thread.updated", + driver, + providerInstanceId, + payload: { + id: sideChatProviderThreadId, + driver, + providerInstanceId, + providerSessionId: null, + appThreadId: sideChatThreadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:run"), + type: "run.updated", + runId: sideChatRunId, + payload: { + id: sideChatRunId, + threadId: sideChatThreadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: sideChatProviderThreadId, + userMessageId: MessageId.make("message:checkpoint-capture-side-chat"), + rootNodeId: sideChatRootNodeId, + activeAttemptId: null, + status: "waiting", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:node"), + type: "node.updated", + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + payload: { + id: sideChatRootNodeId, + threadId: sideChatThreadId, + runId: sideChatRunId, + parentNodeId: null, + rootNodeId: sideChatRootNodeId, + kind: "root_turn", + status: "waiting", + countsForRun: true, + providerThreadId: sideChatProviderThreadId, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: sideChatScopeId, + startedAt: now, + completedAt: null, + }, + }, + { + ...event, + id: EventId.make("event:checkpoint-capture-side-chat:scope"), + type: "checkpoint-scope.created", + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + payload: { + id: sideChatScopeId, + threadId: sideChatThreadId, + runId: sideChatRunId, + nodeId: sideChatRootNodeId, + parentScopeId: null, + providerThreadId: sideChatProviderThreadId, + kind: "root_run", + ordinalWithinParent: 0, + advancesAppRunCount: true, + cwd: "/repo", + createdAt: now, + }, + }, + ]; + yield* Effect.forEach(events, (candidate) => projectionStore.apply(candidate)); + const gitUnavailable = () => Effect.die("side chats must not snapshot the shared worktree"); + const layerCapture = CheckpointCaptureService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + CheckpointService.layer.pipe( + Layer.provide( + Layer.mergeAll( + IdAllocator.layer, + NodeCrypto.layer, + Layer.mock(CheckpointStore.CheckpointStore)({ + isGitRepository: gitUnavailable, + captureCheckpoint: gitUnavailable, + hasCheckpointRef: gitUnavailable, + }), + ), + ), + ), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => + Effect.forEach(input.events, (committed) => projectionStore.apply(committed)).pipe( + Effect.as({ committed: true } as never), + Effect.orDie, + ), + }), + ), + ), + ); + + yield* CheckpointCaptureService.CheckpointCaptureServiceV2.use((service) => + service.execute({ + threadId: sideChatThreadId, + runId: sideChatRunId, + scopeId: sideChatScopeId, + }), + ).pipe(Effect.provide(layerCapture)); + + const { checkpoints, runs } = yield* projectionStore.getThreadRecords(sideChatThreadId, [ + "runs", + "checkpoints", + ]); + assert.equal(runs[0]?.status, "completed"); + const checkpoint = checkpoints.find((candidate) => candidate.id === runs[0]?.checkpointId); + assert.equal(checkpoint?.status, "missing"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 8924943fda29..9d7d3cfd4ae2 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -65,7 +65,7 @@ export const layer: Layer.Layer< readonly runId: RunId; readonly scopeId: CheckpointScopeId; }) { - const { run, rootNode, scope, providerThread, readyCheckpointOrdinals } = + const { run, rootNode, scope, providerThread, readyCheckpointOrdinals, sideChat } = yield* projections.getCheckpointCaptureContext(input.threadId, input); // Stopped runs remain terminal; their checkpoint is the next message's rollback point. const stopped = run?.status === "interrupted" || run?.status === "cancelled"; @@ -109,12 +109,14 @@ export const layer: Layer.Layer< : yield* checkpoints.materializeBaselineCheckpoint({ scope, ordinalWithinScope: 0, + forceMissing: sideChat, }); const baselineCheckpoint = hasReadyCheckpoint(baselineOrdinalWithinScope) ? null : yield* checkpoints.materializeBaselineCheckpoint({ scope, ordinalWithinScope: baselineOrdinalWithinScope, + forceMissing: sideChat, }); const checkpoint = yield* checkpoints.capture({ scope, @@ -123,6 +125,7 @@ export const layer: Layer.Layer< ordinalWithinScope: run.ordinal, appRunOrdinal: run.ordinal, capturedAt, + forceMissing: sideChat, }); // Omit delegatedCompletion to preserve cohort writes made after capture loaded the run. const { delegatedCompletion: _delegatedCompletion, ...runWithoutDelegatedCompletion } = run; diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts index f7290faf9c9c..145de122f387 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.test.ts @@ -64,6 +64,7 @@ it.effect.each([ id: otherId, projectId, deletedAt: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: otherId }, worktreePath: owner === "ancestor" || owner === "conversation" ? parent @@ -202,3 +203,52 @@ it.effect("lets rollback recreate a worktree storage cleanup removed", () => assert.isTrue(isolated); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect.each([ + { sideChat: "answering questions", running: false, toolItems: 0, isolated: true }, + { sideChat: "running", running: true, toolItems: 0, isolated: false }, + { sideChat: "after running tools", running: false, toolItems: 1, isolated: false }, +] as const)( + "a side chat $sideChat shares the parent's worktree: $isolated", + ({ running, toolItems, isolated }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreePath = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-v2-restore-side-chat-", + }); + const parentId = ThreadId.make("restore-side-chat-parent"); + const sideChat = { + id: ThreadId.make("restore-side-chat"), + projectId: ProjectId.make("restore-project"), + deletedAt: null, + worktreePath, + activeRunId: running ? "side-chat-run" : null, + lineage: { + parentThreadId: parentId, + relationshipToParent: "fork", + rootThreadId: parentId, + sideChat: true, + }, + }; + const result = yield* isCheckpointRestoreIsolated( + { id: parentId, worktreePath }, + { cwd: worktreePath }, + { + fileSystem, + path, + projections: { + getShellSnapshot: () => Effect.succeed({ threads: [sideChat], archivedThreads: [] }), + getThreadRecords: () => + Effect.succeed({ + turnItems: Array.from({ length: toolItems }, () => ({ type: "command_execution" })), + }), + getCheckpointContext: () => Effect.succeed({ checkpointScopes: [] }), + getThreadProviderContext: () => Effect.succeed({ providerSessions: [] }), + } as never, + projects: {} as never, + }, + ); + assert.equal(result, isolated); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts index 268549d7bf7f..b7dc08018a89 100644 --- a/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts +++ b/apps/server/src/orchestration-v2/CheckpointRestoreSafety.ts @@ -7,7 +7,7 @@ import type { ProjectionStoreV2 } from "./ProjectionStore.ts"; import type * as ProjectStore from "./ProjectStore.ts"; export const SHARED_WORKSPACE_RESTORE_MESSAGE = - "File restore requires an isolated worktree. This workspace may contain changes from another thread. Rewind the conversation without restoring files instead."; + "File restore requires an isolated worktree. This workspace may contain changes from another thread or a side chat. Rewind the conversation without restoring files instead, or delete side chats you no longer need."; // Check isolation at admission and rollback; checkpoints restore whole checkouts, which may become shared. export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpointRestoreIsolated")( @@ -48,6 +48,21 @@ export const isCheckpointRestoreIsolated = Effect.fn("orchestrationV2.isCheckpoi const checkedPaths = new Set(); for (const otherThread of [...shell.threads, ...shell.archivedThreads]) { if (otherThread.id === thread.id || otherThread.deletedAt !== null) continue; + // An idle side chat that only answered questions never touched the shared worktree. + if ( + otherThread.lineage.sideChat === true && + otherThread.activeRunId === null && + otherThread.worktreePath === worktreePath + ) { + const { turnItems } = yield* projections.getThreadRecords( + otherThread.id, + ["turnItems"], + { + turnItemTypes: ["command_execution", "file_change", "dynamic_tool"], + }, + ); + if (turnItems.length === 0) continue; + } const other = yield* projections.getCheckpointContext(otherThread.id); const providerContext = yield* projections.getThreadProviderContext(otherThread.id); const paths = [ diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts index 2747fcefac79..5990456e64fd 100644 --- a/apps/server/src/orchestration-v2/CheckpointService.ts +++ b/apps/server/src/orchestration-v2/CheckpointService.ts @@ -118,6 +118,8 @@ export interface CheckpointServiceV2Shape { readonly materializeBaselineCheckpoint: (input: { readonly scope: OrchestrationV2CheckpointScope; readonly ordinalWithinScope: number; + /** Records a missing checkpoint without touching git, for side chats sharing a parent's worktree. */ + readonly forceMissing?: boolean; }) => Effect.Effect; readonly capture: (input: { readonly scope: OrchestrationV2CheckpointScope; @@ -126,6 +128,7 @@ export interface CheckpointServiceV2Shape { readonly ordinalWithinScope: number; readonly appRunOrdinal: number | null; readonly capturedAt: DateTime.Utc; + readonly forceMissing?: boolean; }) => Effect.Effect; readonly restore: (input: { readonly scope: OrchestrationV2CheckpointScope; @@ -299,7 +302,8 @@ export const layer: Layer.Layer< scopeId: input.scope.id, ordinalWithinScope: input.ordinalWithinScope, }); - const checkpointable = yield* isGitCheckpointable(input.scope.cwd); + const checkpointable = + input.forceMissing !== true && (yield* isGitCheckpointable(input.scope.cwd)); const available = checkpointable ? yield* checkpointStore .hasCheckpointRef({ @@ -364,7 +368,7 @@ export const layer: Layer.Layer< ordinalWithinScope: Math.max(0, input.ordinalWithinScope - 1), }); - if (!(yield* isGitCheckpointable(input.scope.cwd))) { + if (input.forceMissing === true || !(yield* isGitCheckpointable(input.scope.cwd))) { return makeCheckpoint({ id: checkpointId, scope: input.scope, diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index cf982c9699f7..8ed4de3e9dea 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -260,9 +260,31 @@ layer("CommandPolicyV2", (it) => { hasStrongNativeSource: true, sourceRunStatus: "completed", fromSpecificTurn: true, + sourceBusy: false, }); assert.equal(result, "portable_context"); }), ); + + it.effect("forks a busy source through portable context", () => + Effect.gen(function* () { + const policy = yield* CommandPolicy.CommandPolicyV2; + const decide = (sourceBusy: boolean) => + policy.decideForkExecution({ + commandId, + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: CodexProviderCapabilitiesV2, + sameProvider: true, + hasStrongNativeSource: true, + sourceRunStatus: "completed", + fromSpecificTurn: true, + sourceBusy, + }); + + assert.equal(yield* decide(false), "native_fork"); + assert.equal(yield* decide(true), "portable_context"); + }), + ); }); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.ts b/apps/server/src/orchestration-v2/CommandPolicy.ts index 869851790177..02f329e536fe 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.ts @@ -115,7 +115,8 @@ export function resolveMessageDispatchIntent( : mode; } -function isActiveRun(run: OrchestrationV2Run): boolean { +/** A run that still owns the thread's provider turn. */ +export function isActiveRun(run: OrchestrationV2Run): boolean { return ( run.status === "preparing" || run.status === "starting" || @@ -210,6 +211,8 @@ export interface CommandPolicyV2Shape { readonly hasStrongNativeSource: boolean; readonly sourceRunStatus: OrchestrationV2Run["status"]; readonly fromSpecificTurn: boolean; + /** Later source runs are still active; providers cannot fork a thread mid-turn. */ + readonly sourceBusy: boolean; }, ) => Effect.Effect; readonly ensureRollback: ( @@ -373,6 +376,7 @@ const decideForkExecution: CommandPolicyV2Shape["decideForkExecution"] = (input) (input.sourceRunStatus === "completed" || input.sourceRunStatus === "waiting") && input.sameProvider && input.hasStrongNativeSource && + !input.sourceBusy && input.capabilities.threads.canForkThread && (!input.fromSpecificTurn || input.capabilities.threads.canForkFromTurn) && input.capabilities.identity.nativeThreadIds === "strong"; diff --git a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts index ff2ca1fbec44..adff6f65d8e7 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -12,6 +12,10 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; +/** The provider prompt for a turn that carries inline context ahead of the user's text. */ +export const withInlineContext = (context: string, userText: string): string => + context === "" ? userText : `${context}\n\nUser message:\n${userText}`; + /** Persist around injection; ambiguous delivery requires a fresh native thread. Call unsent if refused before prompt consumption. */ export const deliverContextHandoffs = Effect.fn("orchestrationV2.deliverContextHandoffs")( function* (input: { diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts index aa13f23b9508..d9005e6b6402 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.test.ts @@ -1,11 +1,13 @@ import { assert, it } from "@effect/vitest"; import { MessageId, + ProjectId, ProviderInstanceId, ProviderThreadId, RunId, ThreadId, TurnItemId, + type OrchestrationV2AppThread, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -14,6 +16,7 @@ import * as Layer from "effect/Layer"; import * as ContextHandoffService from "./ContextHandoffService.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { ProjectionStoreThreadNotFoundError } from "./ProjectionStore.ts"; const layerTest = ContextHandoffService.layer.pipe(Layer.provide(IdAllocator.layer)); @@ -156,3 +159,91 @@ it.layer(layerTest)("ContextHandoffService legacy import", (it) => { }), ); }); + +it("caps the side chat context block, keeping the newest parent items", () => { + const parentThreadId = ThreadId.make("thread:side-chat-parent"); + const digest = Array.from({ length: 60 }, (_, index) => + ContextHandoffService.summarizeDeltaItem( + importedItem({ + role: index % 2 === 0 ? "user" : "assistant", + id: `parent-${index}`, + text: `item-${index} ${"x".repeat(300)}`, + ordinal: index, + }), + )!, + ); + const context = ContextHandoffService.makeSideChatContext({ parentThreadId, digest }); + + assert.include(context, "- Earlier items truncated."); + assert.notInclude(context, "item-0 "); + assert.include(context, "- Assistant: item-59 "); + assert.isAtMost(context.length, 8_000); + assert.isTrue( + context.endsWith( + `Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`, + ), + ); + + const framingOnly = ContextHandoffService.makeSideChatContext({ parentThreadId, digest: [] }); + assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread.")); + assert.notInclude(framingOnly, "Main thread activity"); +}); + +it("covers the run range of a very long delta", () => { + const runs = [ + { id: RunId.make("run:long-delta-early"), ordinal: 2 }, + { id: RunId.make("run:long-delta-late"), ordinal: 5 }, + ]; + const items = Array.from({ length: 150_000 }, (_, index) => ({ + runId: runs[index % 2]!.id, + })); + + assert.deepEqual( + ContextHandoffService.visibleDeltaRunOrdinals({ runs }, [...items, { runId: null }]), + { from: 2, to: 5 }, + ); +}); + +it.effect("inherited history stops at a missing fork ancestor", () => + Effect.gen(function* () { + const now = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"); + const threadId = ThreadId.make("thread:fork-of-missing"); + const thread: OrchestrationV2AppThread = { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:fork-of-missing"), + title: "Fork of a missing thread", + providerInstanceId: ProviderInstanceId.make("codex"), + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: { + type: "run", + threadId: ThreadId.make("thread:missing-ancestor"), + runId: RunId.make("run:missing-ancestor"), + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + snoozedUntil: null, + snoozedAt: null, + deletedAt: null, + }; + const items = yield* ContextHandoffService.readInheritedHandoffItems( + { + getThreadRecords: (missing) => + Effect.fail(new ProjectionStoreThreadNotFoundError({ threadId: missing })), + }, + thread, + ); + assert.deepEqual(items, []); + }), +); diff --git a/apps/server/src/orchestration-v2/ContextHandoffService.ts b/apps/server/src/orchestration-v2/ContextHandoffService.ts index 1a1767b4df59..67a474d963bb 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -1,7 +1,9 @@ import { + type OrchestrationV2AppThread, OrchestrationV2ContextHandoff, type OrchestrationV2TurnItem, type OrchestrationV2Run, + type OrchestrationV2ThreadProjection, ProviderInstanceId, ProviderThreadId, RunId, @@ -22,6 +24,8 @@ import { selectHistory, } from "@t3tools/provider-core/server/handoffBudget"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { createOrchestrationV2TurnItemVisibility } from "@t3tools/shared/orchestrationV2Timeline"; +import type { ProjectionStoreV2Shape } from "./ProjectionStore.ts"; export class ContextHandoffPrepareError extends Schema.TaggedError()( "ContextHandoffPrepareError", @@ -80,6 +84,19 @@ export interface ContextHandoffServiceV2Shape { readonly items: ReadonlyArray; readonly createdAt: DateTime.Utc; }) => Effect.Effect; + /** Parent activity after the fork point, prepended to a side chat's first provider turn. */ + readonly prepareSideChatContext: (input: { + readonly threadId: ThreadId; + readonly parentThreadId: ThreadId; + readonly targetRunId: RunId; + readonly transferId: NonNullable; + readonly toProviderThreadId: ProviderThreadId; + readonly fromProviderInstanceId: ProviderInstanceId; + readonly toProviderInstanceId: ProviderInstanceId; + readonly coveredRunOrdinals: OrchestrationV2ContextHandoff["coveredRunOrdinals"]; + readonly parentItems: ReadonlyArray; + readonly createdAt: DateTime.Utc; + }) => Effect.Effect; } export class ContextHandoffServiceV2 extends Context.Service< @@ -95,7 +112,97 @@ function compactText(text: string, maxLength = 240): string { return `${compacted.slice(0, maxLength - 3)}...`; } -function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { +export function visibleDeltaRunOrdinals( + projection: { readonly runs: ReadonlyArray> }, + items: ReadonlyArray>, +): OrchestrationV2ContextHandoff["coveredRunOrdinals"] { + const ordinalByRunId = new Map(projection.runs.map((run) => [run.id, run.ordinal])); + let from = Infinity; + let to = -Infinity; + for (const item of items) { + const ordinal = item.runId === null ? undefined : ordinalByRunId.get(item.runId); + if (ordinal === undefined) continue; + from = Math.min(from, ordinal); + to = Math.max(to, ordinal); + } + return from === Infinity ? { from: 1, to: 1 } : { from, to }; +} + +/** Drops rolled-back runs and cancelled queued messages, which are not part of the conversation. */ +export function visibleConversationItems( + projection: Pick, + items: ReadonlyArray, +): ReadonlyArray { + const visible = createOrchestrationV2TurnItemVisibility({ + runs: projection.runs, + attempts: projection.attempts, + items, + }); + return items.filter(visible); +} + +type ForkHistorySource = Pick; + +/** Visible rows of `source`'s own runs through `throughRun`, plus its v1 imported rows. */ +const readOwnHistoryItems = Effect.fn("orchestrationV2.contextHandoff.readOwnHistoryItems")( + function* ( + projectionStore: Pick, + source: ForkHistorySource, + throughRun: Pick, + ) { + const { turnItems } = yield* projectionStore.getThreadRecords(source.thread.id, ["turnItems"], { + turnItemRunIds: [ + ...source.runs.filter((run) => run.ordinal <= throughRun.ordinal).map((run) => run.id), + ...(source.thread.historyOrigin === "v1_import" ? [null] : []), + ], + }); + return visibleConversationItems(source, turnItems); + }, +); + +/** History `thread` shows from its fork ancestors, oldest first. Side chats inherit no rows. */ +export const readInheritedHandoffItems = Effect.fn( + "orchestrationV2.contextHandoff.readInheritedHandoffItems", +)(function* ( + projectionStore: Pick, + thread: OrchestrationV2AppThread, +) { + const segments: Array> = []; + const seen = new Set([thread.id]); + let current = thread; + while ( + current.forkedFrom?.type === "run" && + current.lineage.sideChat !== true && + !seen.has(current.forkedFrom.threadId) + ) { + const { threadId, runId } = current.forkedFrom; + seen.add(threadId); + const source = yield* projectionStore + .getThreadRecords(threadId, ["runs", "attempts"]) + .pipe(Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) })); + const forkRun = source?.runs.find((run) => run.id === runId); + if (source === null || forkRun === undefined) break; + segments.unshift(yield* readOwnHistoryItems(projectionStore, source, forkRun)); + current = source.thread; + } + return segments.flat(); +}); + +/** What a portable fork from `throughRun` of `source` carries: inherited rows, then its own. */ +export const readForkHistoryItems = Effect.fn( + "orchestrationV2.contextHandoff.readForkHistoryItems", +)(function* ( + projectionStore: Pick, + source: ForkHistorySource, + throughRun: Pick, +) { + return [ + ...(yield* readInheritedHandoffItems(projectionStore, source.thread)), + ...(yield* readOwnHistoryItems(projectionStore, source, throughRun)), + ]; +}); + +export function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null { switch (item.type) { case "user_message": return `- User: ${compactText(item.text)}`; @@ -135,6 +242,41 @@ function makeForkDeltaSummary(input: { ].join("\n"); } +const SIDE_CHAT_CONTEXT_MAX_CHARS = 8_000; + +/** `digest` holds `summarizeDeltaItem` lines for parent items after the fork point, oldest first. */ +export function makeSideChatContext(input: { + readonly parentThreadId: ThreadId; + readonly digest: ReadonlyArray; +}): string { + const framing = + "You are in a side chat beside the main thread. The main thread may still be running in the same worktree. Answer the user's questions; do not continue or redo the main thread's task."; + const header = "Main thread activity since this side chat's starting point:"; + const truncated = "- Earlier items truncated."; + const footer = `Use t3_thread_read on thread ${input.parentThreadId} to see the main thread's live state.`; + const kept: Array = []; + // The cap covers the whole block, so the fixed lines are reserved before any digest line. + let remaining = + SIDE_CHAT_CONTEXT_MAX_CHARS - [framing, "", header, truncated, "", footer].join("\n").length; + for (const line of input.digest.toReversed()) { + if (line.length + 1 > remaining) break; + kept.unshift(line); + remaining -= line.length + 1; + } + return [ + framing, + ...(kept.length === 0 + ? [] + : ["", header, ...(kept.length < input.digest.length ? [truncated] : []), ...kept]), + "", + footer, + ].join("\n"); +} + +/** Side chat context is prepended to the provider prompt, never replayed as budgeted history. */ +export const isSideChatContextHandoff = (handoff: OrchestrationV2ContextHandoff): boolean => + handoff.strategy === "manual_context" && handoff.transferId != null; + function makeLegacyImportSummary(items: ReadonlyArray): string { const sections = items.flatMap((item) => { switch (item.type) { @@ -412,10 +554,56 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi } satisfies OrchestrationV2ContextHandoff; }); + const prepareSideChatContext = Effect.fn( + "orchestrationV2.contextHandoff.prepareSideChatContext", + )(function* (input: Parameters[0]) { + const handoffId = yield* idAllocator.allocate + .contextHandoff({ + threadId: input.threadId, + fromProviderInstanceId: input.fromProviderInstanceId, + toProviderInstanceId: input.toProviderInstanceId, + }) + .pipe( + Effect.mapError( + (cause) => + new ContextHandoffPrepareError({ + threadId: input.threadId, + targetRunId: input.targetRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.toProviderThreadId, + cause, + }), + ), + ); + return { + id: handoffId, + transferId: input.transferId, + threadId: input.threadId, + targetRunId: input.targetRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.toProviderThreadId, + coveredRunOrdinals: input.coveredRunOrdinals, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: makeSideChatContext({ + parentThreadId: input.parentThreadId, + digest: input.parentItems.flatMap((item) => { + const line = summarizeDeltaItem(item); + return line === null ? [] : [line]; + }), + }), + createdByProviderInstanceId: null, + createdAt: input.createdAt, + updatedAt: input.createdAt, + } satisfies OrchestrationV2ContextHandoff; + }); + return ContextHandoffServiceV2.of({ prepareLegacyImport, prepareForkDelta, prepareProviderHandoff, + prepareSideChatContext, }); }, ); diff --git a/apps/server/src/orchestration-v2/IdleCompaction.test.ts b/apps/server/src/orchestration-v2/IdleCompaction.test.ts index bfa36cd2e513..65360492bd2d 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.test.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.test.ts @@ -148,7 +148,7 @@ function records( } describe("idleCompactionWindow", () => { - it("skips small contexts, unknown expiry, other providers, switched threads, and compactions", () => { + it("skips small contexts, unknown expiry, other providers, switched threads, compactions, and side chats", () => { const cache = { cacheExpiresInMs: 60 * MINUTE_MS }; expect(idleCompactionWindow(records({ ...cache, usedTokens: 199_999 }), NOW_MS)).toBeNull(); expect(idleCompactionWindow(records(), NOW_MS)).toBeNull(); @@ -166,6 +166,13 @@ describe("idleCompactionWindow", () => { const archived = records(cache); archived.thread = { ...archived.thread, archivedAt: at() }; expect(idleCompactionWindow(archived, NOW_MS)).toBeNull(); + const sideChat = records(cache); + expect(idleCompactionWindow(sideChat, NOW_MS)).not.toBeNull(); + sideChat.thread = { + ...sideChat.thread, + lineage: { ...sideChat.thread.lineage, relationshipToParent: "fork", sideChat: true }, + }; + expect(idleCompactionWindow(sideChat, NOW_MS)).toBeNull(); }); }); diff --git a/apps/server/src/orchestration-v2/IdleCompaction.ts b/apps/server/src/orchestration-v2/IdleCompaction.ts index b142dd4efb19..a89894045c29 100644 --- a/apps/server/src/orchestration-v2/IdleCompaction.ts +++ b/apps/server/src/orchestration-v2/IdleCompaction.ts @@ -1,4 +1,4 @@ -import type { OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import { isListedThreadLineage, type OrchestrationV2ThreadProjection } from "@t3tools/contracts"; import { COMPACTION_SUGGESTION_MIN_TOKENS } from "@t3tools/shared/claudeCompaction"; import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as DateTime from "effect/DateTime"; @@ -50,7 +50,7 @@ export function idleCompactionWindow( if (thread.settledAt !== null || thread.settledOverride === "settled") return null; if (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) return null; - if (thread.lineage.relationshipToParent === "subagent") return null; + if (!isListedThreadLineage(thread.lineage)) return null; const latestRun = runs.reduce<(typeof runs)[number] | null>( (latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest), null, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6c1ff428400b..0a5e56d98d63 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -59,6 +59,7 @@ import { latestProviderTurnForAttempt, parseGoalCommand, orchestrationV2RunWorkStartedAt, + orchestrationV2WorktreePending, ProviderInstanceId, ProviderInteractionMode, resolveProviderInstanceEnabled, @@ -99,11 +100,18 @@ import { CheckpointServiceV2 } from "./CheckpointService.ts"; import { CommandPolicyV2, goalCommandStopsActiveRun, + isActiveRun, isNativeMaintenanceCommand, resolveMessageDispatchIntent, } from "./CommandPolicy.ts"; import { CommandReceiptStoreV2 } from "./CommandReceiptStore.ts"; -import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; +import { + ContextHandoffServiceV2, + readForkHistoryItems, + readInheritedHandoffItems, + visibleConversationItems, + visibleDeltaRunOrdinals, +} from "./ContextHandoffService.ts"; import { notificationTurnItem } from "@t3tools/provider-core/server/notification"; import { cancelledBeforeStart, @@ -637,15 +645,6 @@ function mayStartSubagentFollowup( return command.createdBy === "user" || command.creationSource === "mcp"; } -function isBlockingRun(run: OrchestrationV2Run): boolean { - return ( - run.status === "preparing" || - run.status === "starting" || - run.status === "running" || - run.status === "waiting" - ); -} - /** Waiting runs only drain post-terminal output; their agent turn is over and still needs a wake. */ function isLiveRun(run: OrchestrationV2Run): boolean { return run.status === "preparing" || run.status === "starting" || run.status === "running"; @@ -919,26 +918,6 @@ function latestContextTransfer( }, undefined); } -function visibleDeltaRunOrdinals( - projection: Pick, - items: ReadonlyArray, -): OrchestrationV2ContextHandoff["coveredRunOrdinals"] { - const ordinals = items.flatMap((item) => { - if (item.runId === null) { - return []; - } - const run = projection.runs.find((candidate) => candidate.id === item.runId); - return run === undefined ? [] : [run.ordinal]; - }); - if (ordinals.length === 0) { - return { from: 1, to: 1 }; - } - return { - from: Math.min(...ordinals), - to: Math.max(...ordinals), - }; -} - export function shouldPrepareLegacyImportHandoff(input: { readonly hasCompletedRun: boolean; readonly historyOrigin: OrchestrationV2AppThread["historyOrigin"]; @@ -1199,6 +1178,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), ); + const readInheritedItems = (thread: OrchestrationV2AppThread) => + readInheritedHandoffItems(projectionStore, thread).pipe( + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId: thread.id, cause })), + ); + + /** Includes tombstones, which still reserve their id. */ + const findThread = (threadId: ThreadId) => + projectionStore.getThread(threadId).pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + ); + + /** Side chats live inside their parent, so deleting the parent deletes them too. */ + const liveSideChatIds = Effect.fn("Orchestrator.liveSideChatIds")( + function* (threadId: ThreadId) { + const { contextTransfers } = yield* projectionStore + .getThreadRecords(threadId, ["contextTransfers"]) + .pipe( + Effect.catchTags({ + ProjectionStoreThreadNotFoundError: () => Effect.succeed({ contextTransfers: [] }), + }), + ); + const sideChatIds: Array = []; + for (const transfer of contextTransfers) { + if (transfer.type !== "fork" || transfer.sourceThreadId !== threadId) continue; + const child = yield* projectionStore + .getThread(transfer.targetThreadId) + .pipe( + Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(null) }), + ); + if (child?.lineage.sideChat === true && child.deletedAt === null) { + sideChatIds.push(child.id); + } + } + return sideChatIds; + }, + (effect, threadId) => + effect.pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + ); + const getProjectionWithPendingEvents = /* This helper runs for individual orchestration events; the enclosing operation owns the span. */ Effect.fnUntraced(function* ( @@ -1527,7 +1546,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || - projection.runs.some(isBlockingRun) + projection.runs.some(isActiveRun) ) { return; } @@ -4246,26 +4265,55 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // The source is unlocked; validate the modes copied here, not an earlier read. + // Only side chat forks lock the source; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); + if (command.sideChat === true && sourceProjection.thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.sourceThreadId} was deleted.`, + }); + } + // The fork would copy no worktree path and run in the project checkout. + if ( + command.sideChat === true && + orchestrationV2WorktreePending(sourceProjection.thread.worktreePath, sourceProjection.runs) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.sourceThreadId} has no worktree yet. Start the side chat once its worktree is set up.`, + }); + } + if ((yield* findThread(command.targetThreadId)) !== null) { + return yield* new OrchestratorThreadAlreadyExistsError({ + commandId: command.commandId, + threadId: command.targetThreadId, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); - if (sourceRun === null) { + // A side chat may open during the parent's first turn; it starts from a digest instead. + if ( + sourceRun === null && + !(command.sideChat === true && command.sourcePoint.type === "latest_stable") + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, cause: `No stable source run was found for fork source ${command.sourcePoint.type}.`, }); } - if (!isForkableSourceRunStatus(sourceRun.status)) { + if (sourceRun !== null && !isForkableSourceRunStatus(sourceRun.status)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, cause: forkableSourceRunStatusError(sourceRun), }); } - const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun); + const sourceProviderThread = + sourceRun === null ? undefined : providerThreadForRun(sourceProjection, sourceRun); const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); const transferId = yield* mapDispatchError(command)( @@ -4280,11 +4328,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio sourceProjection, sourceRun, sourceProviderThread, - sourceTokenUsage: providerTurnForRun(sourceProjection, sourceRun)?.tokenUsage, - canonicalSourcePoint: contextSourcePointForRun(sourceProjection, sourceRun), + sourceTokenUsage: + sourceRun === null + ? undefined + : providerTurnForRun(sourceProjection, sourceRun)?.tokenUsage, + canonicalSourcePoint: + sourceRun === null + ? { threadId: sourceProjection.thread.id } + : contextSourcePointForRun(sourceProjection, sourceRun), transferId, targetThreadId: command.targetThreadId, ...(command.title === undefined ? {} : { title: command.title }), + ...(command.sideChat ? { sideChat: true } : {}), createdBy: command.createdBy, creationSource: command.creationSource, createdAt: now, @@ -4301,7 +4356,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio yield* emitEvent({ type: "context-transfer.created", threadId: command.targetThreadId, - providerInstanceId: sourceRun.providerInstanceId, + providerInstanceId: transfer.sourceProviderInstanceId ?? targetThread.providerInstanceId, occurredAt: now, payload: transfer, }); @@ -4361,6 +4416,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.sourceThreadId} is not a fork of ${command.targetThreadId}.`, }); } + // Queued runs cannot consume merge-back context. Refuse under the target's + // command lock before a pending transfer can block its ordinary messages. + if (targetProjection.runs.some((run) => isActiveRun(run) || run.status === "queued")) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "Finish or stop the target thread's active work and clear its queue before merging back.", + }); + } + const otherPendingMergeBack = targetProjection.contextTransfers.some( + (transfer) => + transfer.type === "merge_back" && + transfer.status === "pending" && + transfer.targetThreadId === command.targetThreadId && + transfer.sourceThreadId !== command.sourceThreadId, + ); + if (otherPendingMergeBack) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.targetThreadId} already has pending context from another side chat or fork. Send a message there first.`, + }); + } const sourceRun = runForSourcePoint(sourceProjection, command.sourcePoint); if (sourceRun === null) { @@ -4392,17 +4471,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } - // Queued runs cannot consume merge-back context. Refuse under the target's - // command lock before a pending transfer can block its ordinary messages. - if (targetProjection.runs.some((run) => isBlockingRun(run) || run.status === "queued")) { - return yield* new OrchestratorDispatchError({ - commandId: command.commandId, - commandType: command.type, - cause: - "Finish or stop the target thread's active work and clear its queue before merging back.", - }); - } - const sourceProviderThread = providerThreadForRun(sourceProjection, sourceRun); const now = command.createdAt ?? (yield* DateTime.now); const emitEvent = emit(events, command); @@ -5652,7 +5720,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const activeProviderThread = projection.providerThreads.find( (candidate) => candidate.id === projection.thread.activeProviderThreadId, ); - const activeRun = projection.runs.find(isBlockingRun); + const activeRun = projection.runs.find(isActiveRun); // A message queued during worktree setup can arrive after that setup failed // without a worktree. It waits, held, like the messages queued before the // failure, instead of starting in the project checkout. @@ -6432,8 +6500,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio sourceProjection.attempts.find((candidate) => candidate.id === sourceRun.activeAttemptId) ?.providerTurnId ?? undefined); + const sideChatFork = + pendingForkTransfer !== undefined && projection.thread.lineage.sideChat === true; + // Opened during the parent's first turn: the digest is the whole context. + const sideChatWithoutSource = + sideChatFork && pendingForkTransfer.sourcePoint.runId === undefined; if (pendingForkTransfer !== undefined) { - if (sourceRun === null || sourceProviderThread === undefined) { + if (!sideChatWithoutSource && (sourceRun === null || sourceProviderThread === undefined)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -6498,6 +6571,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + const sourceRunsAfterFork = + sourceProjection === null + ? [] + : sourceProjection.runs.filter( + (candidate) => sourceRun === null || candidate.ordinal > sourceRun.ordinal, + ); const forkExecution = pendingForkTransfer === undefined || sourceRun === null ? null @@ -6512,6 +6591,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio hasStrongNativeSource: sourceProviderThread?.nativeThreadRef?.strength === "strong", sourceRunStatus: sourceRun.status, fromSpecificTurn: sourceRun !== null, + sourceBusy: sourceRunsAfterFork.some(isActiveRun), }), ); const canResolveForkNatively = forkExecution === "native_fork"; @@ -6568,12 +6648,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const portableForkItems = !requiresPortableFork || sourceProjection === null || sourceRun === null ? [] - : yield* readHandoffItems(sourceProjection.thread.id, [ - ...sourceProjection.runs - .filter((run) => run.ordinal <= sourceRun.ordinal) - .map((run) => run.id), - ...(sourceProjection.thread.historyOrigin === "v1_import" ? [null] : []), - ]); + : yield* readForkHistoryItems(projectionStore, sourceProjection, sourceRun).pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ threadId: sourceProjection.thread.id, cause }), + ), + ); const portableForkHandoff = !requiresPortableFork || pendingForkTransfer === undefined || @@ -6606,6 +6686,46 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); + // Without a source run the digest covers the parent's whole history: inherited fork rows + // first, so the cap truncates them first, then imported and run items. + const sideChatParentRunIds = [ + // Queued messages have not reached the main thread yet. + ...sourceRunsAfterFork.filter((run) => run.status !== "queued").map((run) => run.id), + ...(sourceRun === null && sourceProjection?.thread.historyOrigin === "v1_import" + ? [null] + : []), + ]; + const sideChatParentItems = + !sideChatFork || sourceProjection === null + ? [] + : [ + ...(sideChatWithoutSource ? yield* readInheritedItems(sourceProjection.thread) : []), + ...(sideChatParentRunIds.length === 0 + ? [] + : visibleConversationItems( + sourceProjection, + yield* readHandoffItems(sourceProjection.thread.id, sideChatParentRunIds), + )), + ]; + // The framing note goes in even when the parent has no activity after the fork point. + const sideChatContextHandoff = + !sideChatFork || pendingForkTransfer === undefined || sourceProjection === null + ? null + : yield* contextHandoffService + .prepareSideChatContext({ + threadId: command.threadId, + parentThreadId: sourceProjection.thread.id, + targetRunId: runId, + transferId: pendingForkTransfer.id, + toProviderThreadId: ensuredProviderThread.id, + fromProviderInstanceId: + pendingForkTransfer.sourceProviderInstanceId ?? modelSelection.instanceId, + toProviderInstanceId: modelSelection.instanceId, + coveredRunOrdinals: visibleDeltaRunOrdinals(sourceProjection, sideChatParentItems), + parentItems: sideChatParentItems, + createdAt: now, + }) + .pipe(mapDispatchError(command)); const requiresFullProviderSwitchContext = isProviderSwitch && pendingMergeBackTransfer !== undefined; const targetLastCompletedRun = @@ -6722,9 +6842,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio lastRunOrdinal: ordinal, handoffIds: [ ...ensuredProviderThread.handoffIds, - ...[portableForkHandoff, providerSwitchHandoff, legacyImportRecoveryHandoff].flatMap( - (handoff) => (handoff === null ? [] : [handoff.id]), - ), + ...[ + portableForkHandoff, + sideChatContextHandoff, + providerSwitchHandoff, + legacyImportRecoveryHandoff, + ].flatMap((handoff) => (handoff === null ? [] : [handoff.id])), ], updatedAt: now, }; @@ -6790,11 +6913,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const mergeBackDeltaItems = mergeBackSourceProjection === null || mergeBackSourceRun === null ? [] - : yield* readHandoffItems( - mergeBackSourceProjection.thread.id, - mergeBackSourceProjection.runs - .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) - .map((run) => run.id), + : visibleConversationItems( + mergeBackSourceProjection, + yield* readHandoffItems( + mergeBackSourceProjection.thread.id, + mergeBackSourceProjection.runs + .filter((run) => run.ordinal <= mergeBackSourceRun.ordinal) + .map((run) => run.id), + ), ); const mergeBackHandoff = pendingMergeBackTransfer === undefined || @@ -7027,12 +7153,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio strategy: "native_fork", providerThreadRef: providerThread.nativeThreadRef, }; + const portableForkContext = + portableForkHandoff ?? (sideChatWithoutSource ? sideChatContextHandoff : null); const portableForkResolution: OrchestrationV2ContextTransferResolution | null = - pendingForkTransfer === undefined || portableForkHandoff === null + pendingForkTransfer === undefined || portableForkContext === null ? null : { strategy: "portable_context", - contextHandoffId: portableForkHandoff.id, + contextHandoffId: portableForkContext.id, }; const mergeBackResolution: OrchestrationV2ContextTransferResolution | null = pendingMergeBackTransfer === undefined || mergeBackHandoff === null @@ -7095,6 +7223,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio payload: portableForkHandoff, }); } + if (sideChatContextHandoff !== null) { + yield* emitEvent({ + type: "context-handoff.updated", + threadId: command.threadId, + runId, + providerInstanceId: modelSelection.instanceId, + occurredAt: now, + payload: sideChatContextHandoff, + }); + } if (legacyImportRecoveryHandoff !== null) { yield* emitEvent({ type: "context-handoff.updated", @@ -7354,7 +7492,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); - if (parentRun === undefined || !isBlockingRun(parentRun)) { + if (parentRun === undefined || !isActiveRun(parentRun)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -7658,7 +7796,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (original === undefined || original.result === null) return; const owner = - parent.runs.filter(isBlockingRun).toSorted((a, b) => b.ordinal - a.ordinal)[0] ?? + parent.runs.filter(isActiveRun).toSorted((a, b) => b.ordinal - a.ordinal)[0] ?? latestExecutedRun(parent.runs); if (owner === null || owner.rootNodeId === null) return; const now = message.createdAt; @@ -7785,7 +7923,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const parentRun = parentProjection.runs.find( (candidate) => candidate.id === command.parentRunId, ); - if (parentRun === undefined || !isBlockingRun(parentRun) || parentRun.rootNodeId === null) { + if (parentRun === undefined || !isActiveRun(parentRun) || parentRun.rootNodeId === null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -8507,7 +8645,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } if ( existing === undefined && - (!isBlockingRun(run) || (yield* stopReachedRun(command, command.threadId, run.id))) + (!isActiveRun(run) || (yield* stopReachedRun(command, command.threadId, run.id))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -9916,7 +10054,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Run ${command.runId} has no failed workspace preparation to retry.`, }); } - if (projection.runs.some((run) => run.id !== state.run.id && isBlockingRun(run))) { + if (projection.runs.some((run) => run.id !== state.run.id && isActiveRun(run))) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -11416,7 +11554,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio continue; const idle = idleChildren.get(task.childThreadId) ?? - (!child.runs.some((run) => run.status === "queued" || isBlockingRun(run)) && + (!child.runs.some((run) => run.status === "queued" || isActiveRun(run)) && !child.providerThreads.some( (thread) => thread.status === "active" || (thread.pendingBackgroundTasks?.length ?? 0) > 0, @@ -12689,6 +12827,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchOnce = Effect.fn("orchestrationV2.dispatch.once")(function* ( command: OrchestrationV2ServerCommand, + sideChatIds: ReadonlyArray, ): Effect.fn.Return< { readonly events: ReadonlyArray; @@ -12755,31 +12894,64 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio break; } case "thread.delete": { - const projection = yield* projectionStore - .getThreadRecords(command.threadId, [ - "runs", - "attempts", - "nodes", - "runtimeRequests", - "subagents", - "providerSessions", - ]) - .pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), + const now = yield* DateTime.now; + const planDeletion = Effect.fn("Orchestrator.planDeletion")(function* ( + deletion: typeof command, + ) { + const projection = yield* projectionStore + .getThreadRecords(deletion.threadId, [ + "runs", + "attempts", + "nodes", + "runtimeRequests", + "subagents", + "providerSessions", + ]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: deletion.threadId, cause }), + ), + ); + return yield* mapDispatchError(command)( + planThreadDeletion({ + command: deletion, + projection, + attachmentIds: yield* projectionStore + .getThreadAttachmentIds(deletion.threadId) + .pipe(mapDispatchError(command)), + now, + idAllocator, + }), ); - return yield* mapDispatchError(command)( - planThreadDeletion({ - command, - projection, - attachmentIds: yield* projectionStore - .getThreadAttachmentIds(command.threadId) - .pipe(mapDispatchError(command)), - now: yield* DateTime.now, - idAllocator, - }), - ); + }); + // Checked under the thread lock: a message sent since the client looked keeps the thread. + if (command.onlyIfUnused === true) { + const usage = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "messages"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), + ), + ); + if (usage.runs.length > 0 || usage.messages.length > 0) { + return { events: [], effects: [] }; + } + } + // The dispatch wrapper holds each side chat's lock, so none can commit after this plan. + const plans = [ + yield* planDeletion(command), + ...(yield* Effect.forEach(sideChatIds, (threadId) => + planDeletion({ + ...command, + commandId: CommandId.make(`${command.commandId}:side-chat:${threadId}`), + threadId, + }), + )), + ]; + return { + events: plans.flatMap((plan) => plan.events), + effects: plans.flatMap((plan) => plan.effects), + }; } case "thread.archive": case "thread.unarchive": @@ -12943,7 +13115,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio selectedMessage === undefined || selectedMessage.delegatedCompletion !== undefined || selectedMessage.notification !== undefined || - projection.runs.some(isBlockingRun) + projection.runs.some(isActiveRun) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -13067,8 +13239,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; }); + /** `sideChatIds` are the live side chats a `thread.delete` cascades to, read under their locks. */ const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* ( command: OrchestrationV2ServerCommand, + sideChatIds: ReadonlyArray = [], ): Effect.fn.Return { yield* Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, @@ -13144,7 +13318,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell); } - const plan = yield* dispatchOnce(command).pipe( + const plan = yield* dispatchOnce(command, sideChatIds).pipe( Effect.filterOrFail( (planned) => planned.events.length > 0 || @@ -13153,7 +13327,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "thread.stop" || command.type === "subagent.stop" || // Closing a share again, or after it ended, is not an error. - command.type === "thread.guest-share.close", + command.type === "thread.guest-share.close" || + (command.type === "thread.delete" && command.onlyIfUnused === true), (_planned) => new OrchestratorDispatchError({ commandId: command.commandId, @@ -13295,6 +13470,32 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandThreadId(command), dispatchWithReceiptEffect(command), ); + // A parent's lock comes before its side chats' locks, so a delete cascade and a side chat + // fork or command never interleave. A fork from a side chat or onto an existing thread would + // invert that order; dispatch rejects both, so they skip the source lock. + if ( + command.type === "thread.fork" && + command.sideChat === true && + command.sourceThreadId !== command.targetThreadId + ) { + const source = yield* findThread(command.sourceThreadId); + const target = yield* findThread(command.targetThreadId); + return yield* source === null || source.lineage.sideChat === true || target !== null + ? dispatch + : threadDispatch.withLock(command.sourceThreadId, dispatch); + } + if (command.type === "thread.delete") + return yield* threadDispatch.withLock( + command.threadId, + liveSideChatIds(command.threadId).pipe( + Effect.flatMap((sideChatIds) => + sideChatIds.reduce( + (locked, sideChatId) => threadDispatch.withLock(sideChatId, locked), + dispatchWithReceiptEffect(command, sideChatIds), + ), + ), + ), + ); // Answering a message-mode question can also start a follow-up. if ( command.type !== "runtime-request.respond" && diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index cfe57ab2c0cb..a29d698d76da 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5086,13 +5086,14 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); - it.effect("counts imported runless history inherited by fork shells", () => + it.effect("counts history inherited by fork shells, but not by side chats", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const now = yield* DateTime.now; const projectId = ProjectId.make("project:projection-imported-fork-shell"); const sourceThreadId = ThreadId.make("thread:projection-imported-fork-shell:source"); const targetThreadId = ThreadId.make("thread:projection-imported-fork-shell:target"); + const sideChatThreadId = ThreadId.make("thread:projection-imported-fork-shell:side-chat"); const sourceRunId = RunId.make("run:projection-imported-fork-shell:source"); const rootNodeId = NodeId.make("node:projection-imported-fork-shell:source"); @@ -5130,43 +5131,49 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { deletedAt: null, }, }); - yield* projectionStore.apply({ - id: EventId.make("event:projection-imported-fork-shell:target-thread"), - type: "thread.created", - threadId: targetThreadId, - occurredAt: now, - payload: { - createdBy: "user", - creationSource: "web", - id: targetThreadId, - projectId, - title: "Imported fork target", - providerInstanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: null, - lineage: { - parentThreadId: sourceThreadId, - relationshipToParent: "fork", - rootThreadId: sourceThreadId, - }, - forkedFrom: { - type: "run", - threadId: sourceThreadId, - runId: sourceRunId, + for (const [threadId, sideChat] of [ + [targetThreadId, false], + [sideChatThreadId, true], + ] as const) { + yield* projectionStore.apply({ + id: EventId.make(`event:projection-imported-fork-shell:created:${threadId}`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Imported fork target", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: sourceThreadId, + relationshipToParent: "fork", + rootThreadId: sourceThreadId, + sideChat, + }, + forkedFrom: { + type: "run", + threadId: sourceThreadId, + runId: sourceRunId, + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, }, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - }); + }); + } yield* projectionStore.apply({ id: EventId.make("event:projection-imported-fork-shell:source-run"), type: "run.created", @@ -5235,6 +5242,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.equal(targetShell.itemCount, 0); assert.equal(targetShell.visibleItemCount, 4); assert.equal(targetProjection.visibleTurnItems.length, 4); + + const sideChatShell = shell.threads.find((thread) => thread.id === sideChatThreadId); + assert.equal(sideChatShell?.visibleItemCount, 0); + const sideChatProjection = yield* projectionStore.getThreadProjection(sideChatThreadId); + assert.deepEqual(sideChatProjection.visibleTurnItems, []); + const sideChatTimeline = yield* projectionStore.getTimelinePage(sideChatThreadId, { + limit: 20, + view: "activity", + }); + assert.equal(sideChatTimeline.totalItems, 0); }), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 84c650f75fe3..2e88fdc11908 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -256,6 +256,8 @@ export interface ProjectionCheckpointCaptureContext { readonly scope: OrchestrationV2CheckpointScope | undefined; readonly providerThread: OrchestrationV2ProviderThread | undefined; readonly readyCheckpointOrdinals: ReadonlyArray; + /** Side chats share their parent's worktree and never snapshot it. */ + readonly sideChat: boolean; } const RollbackTargetAttempts = Schema.Array( @@ -1335,14 +1337,21 @@ function visibleTurnItemsThroughRun(input: { return [...inheritedPrefix, ...localPrefix]; } +// Side chats show only their own rows; the fork transfer carries the parent's context to the model. +function inheritedRunFork(thread: Pick) { + return thread.forkedFrom?.type === "run" && thread.lineage.sideChat !== true + ? thread.forkedFrom + : null; +} + function buildVisibleTurnItems(input: { readonly projection: OrchestrationV2ThreadProjection; readonly sourceProjection: OrchestrationV2ThreadProjection | null; readonly inheritedRowCount?: number; }): Array { - const forkedFrom = input.projection.thread.forkedFrom; + const forkedFrom = inheritedRunFork(input.projection.thread); if ( - forkedFrom?.type !== "run" || + forkedFrom === null || (input.sourceProjection === null && input.inheritedRowCount === undefined) ) { return localVisibleTurnItems(input.projection); @@ -1721,8 +1730,8 @@ function visibleItemCountForShell(input: { return 0; } - const forkedFrom = state.thread.forkedFrom; - if (forkedFrom?.type !== "run") { + const forkedFrom = inheritedRunFork(state.thread); + if (forkedFrom === null) { return state.itemCount; } @@ -1736,9 +1745,8 @@ function visibleItemCountForShell(input: { return state.itemCount; } - const sourceForkedFrom = sourceState.thread.forkedFrom; const inheritedPrefixCount = - sourceForkedFrom?.type === "run" + inheritedRunFork(sourceState.thread) !== null ? visibleItemCountForShell({ threadId: sourceState.thread.id, statesByThreadId: input.statesByThreadId, @@ -3628,8 +3636,8 @@ export const layer: Layer.Layer = ? ["runs", "attempts", "turnItems"] : undefined, ); - const forkedFrom = projection.thread.forkedFrom; - if (forkedFrom?.type !== "run" || seenThreadIds.has(forkedFrom.threadId)) { + const forkedFrom = inheritedRunFork(projection.thread); + if (forkedFrom === null || seenThreadIds.has(forkedFrom.threadId)) { return withLocalVisibleTurnItems(projection); } @@ -4305,6 +4313,8 @@ export const layer: Layer.Layer = message_id IN (SELECT json_extract(payload_json, '$.userMessageId') FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND run_id = ${runId}) OR (role = 'user' AND lower(trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace})) = '/compact') + -- Earlier /goal turns defer context too; parseGoalCommand narrows this prefix match. + OR (role = 'user' AND trim(json_extract(payload_json, '$.text'), ${javascriptTrimWhitespace}) GLOB '/goal*') ) ORDER BY created_at ASC, message_id ASC `.pipe(Effect.flatMap(decodeRows(decodeMessagePayload, threadId))); const conversation = yield* sql<{ present: number }>` @@ -5064,7 +5074,12 @@ export const layer: Layer.Layer = sql .withTransaction( Effect.gen(function* () { - yield* requireThread(threadId); + const threadRows = yield* sql<{ readonly side_chat: number | null }>` + SELECT json_extract(payload_json, '$.lineage.sideChat') AS side_chat + FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId} LIMIT 1`; + if (threadRows[0] === undefined) { + return yield* new ProjectionStoreThreadNotFoundError({ threadId }); + } const runRows = yield* sql` SELECT payload_json FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND run_id = ${target.runId}`; @@ -5101,6 +5116,7 @@ export const layer: Layer.Layer = ? undefined : yield* decodeProviderThreadPayload(providerRows[0].payload_json), readyCheckpointOrdinals: readyRows.map(({ ordinal }) => ordinal), + sideChat: threadRows[0].side_chat === 1, }; }), ) @@ -5504,8 +5520,8 @@ export const layer: Layer.Layer = items, }); const visible = local.filter((row) => isVisible(row.item)); - const fork = records.thread.forkedFrom; - if (fork?.type !== "run" || seen.has(fork.threadId)) return { records, local, visible }; + const fork = inheritedRunFork(records.thread); + if (fork === null || seen.has(fork.threadId)) return { records, local, visible }; const source = yield* readTimelineIndex( fork.threadId, new Set([...seen, threadId]), @@ -5865,6 +5881,7 @@ export const layer: Layer.Layer = t.payload_json, CASE WHEN json_extract(t.payload_json, '$.forkedFrom.type') = 'run' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 THEN json_extract(t.payload_json, '$.forkedFrom.threadId') ELSE NULL END AS forked_from_run_source_thread_id, @@ -6749,6 +6766,7 @@ export const layer: Layer.Layer = readonly relationship_to_parent: string | null; readonly parent_thread_id: string | null; readonly root_thread_id: string; + readonly side_chat: number | null; readonly settled_override: string | null; readonly settled_at: string | null; readonly pinned_at: string | null; @@ -6761,6 +6779,7 @@ export const layer: Layer.Layer = json_extract(t.payload_json, '$.lineage.relationshipToParent') AS relationship_to_parent, json_extract(t.payload_json, '$.lineage.parentThreadId') AS parent_thread_id, json_extract(t.payload_json, '$.lineage.rootThreadId') AS root_thread_id, + json_extract(t.payload_json, '$.lineage.sideChat') AS side_chat, json_extract(t.payload_json, '$.settledOverride') AS settled_override, json_extract(t.payload_json, '$.settledAt') AS settled_at, json_extract(t.payload_json, '$.pinnedAt') AS pinned_at, @@ -6815,6 +6834,7 @@ export const layer: Layer.Layer = ? row.relationship_to_parent : null, rootThreadId: ThreadId.make(row.root_thread_id), + ...(row.side_chat === null ? {} : { sideChat: row.side_chat === 1 }), }, settledOverride: row.settled_override === "settled" || row.settled_override === "active" @@ -6850,6 +6870,7 @@ export const layer: Layer.Layer = WHERE t.deleted_at IS NULL AND json_extract(t.payload_json, '$.archivedAt') IS NULL AND json_extract(t.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(t.payload_json, '$.settledOverride') = 'settled' AND json_extract(t.payload_json, '$.settledAt') IS NOT NULL${ projectIds === undefined ? sql`` : sql` AND t.project_id IN ${sql.in(projectIds)}` diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d9287644be82..05fc1b8545bb 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -3,6 +3,8 @@ import { expect, it, vi } from "vite-plus/test"; import { it as effectIt } from "@effect/vitest"; import { CheckpointScopeId, + ContextHandoffId, + ContextTransferId, GitCommandError, MessageId, NodeId, @@ -40,6 +42,7 @@ import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderAuthService from "../provider/ProviderAuthService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; +import { withInlineContext } from "./ContextHandoffDelivery.ts"; import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -96,6 +99,7 @@ function startWithMissingWorktree( projectId: ProjectId.make("project_provider_turn_start_projection_failure"), branch: "feature/restore", worktreePath, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, }, runs: [ { @@ -286,6 +290,16 @@ function makeLocalCommandHarness(input: { readonly interruptFailure?: boolean; readonly interruptCurrentRun?: boolean; }; + /** + * A side chat whose context targets this run, an earlier run that failed or was cancelled before + * the provider, or an earlier run that delivered it. `nativeForkFrom` plans a native fork of the + * parent. `switchedProvider` puts the earlier run and its context on another provider thread. + */ + readonly sideChat?: { + readonly contextRun: "current" | "failed" | "cancelled" | "delivered"; + readonly nativeForkFrom?: "idle" | "busy"; + readonly switchedProvider?: boolean; + }; }) { const now = DateTime.makeUnsafe("2026-09-04T12:00:00Z"); const threadId = ThreadId.make("thread-native-account-command"); @@ -354,6 +368,7 @@ function makeLocalCommandHarness(input: { projectId: ProjectId.make("project-native-account-command"), branch: input.worktreePath === undefined ? null : "feature/restore", worktreePath: input.worktreePath ?? null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, } as OrchestrationV2ThreadProjection["thread"], runs: [ ...(input.previousNativeSession @@ -458,6 +473,139 @@ function makeLocalCommandHarness(input: { checkpoints: [], updatedAt: now, }; + const parentThreadId = ThreadId.make("side-chat-parent"); + const parentRunId = RunId.make("side-chat-parent-run"); + const parentProviderThread = { + ...providerThread, + id: ProviderThreadId.make("side-chat-parent-provider-thread"), + appThreadId: parentThreadId, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: "side-chat-parent-native", + strength: "strong" as const, + }, + }; + const parentRecords = { + ...projection, + thread: { ...projection.thread, id: parentThreadId }, + runs: [ + { + ...run, + id: parentRunId, + threadId: parentThreadId, + ordinal: 1, + providerThreadId: parentProviderThread.id, + activeAttemptId: null, + status: "completed" as const, + completedAt: now, + }, + ...(input.sideChat?.nativeForkFrom === "busy" + ? [ + { + ...run, + id: RunId.make("side-chat-parent-busy-run"), + threadId: parentThreadId, + ordinal: 2, + providerThreadId: parentProviderThread.id, + status: "running" as const, + }, + ] + : []), + ], + providerThreads: [parentProviderThread], + attempts: [], + providerTurns: [], + turnItems: [], + }; + if (input.sideChat !== undefined) { + const earlierRunId = RunId.make("side-chat-earlier-run"); + projection = { + ...projection, + thread: { + ...projection.thread, + runtimeMode: "full-access", + lineage: { + parentThreadId, + relationshipToParent: "fork", + rootThreadId: parentThreadId, + sideChat: true, + }, + }, + runs: + input.sideChat.contextRun === "current" + ? projection.runs + : [ + { + ...run, + id: earlierRunId, + ordinal: 1, + userMessageId: MessageId.make("side-chat-earlier-message"), + activeAttemptId: RunAttemptId.make("side-chat-earlier-attempt"), + ...(input.sideChat.switchedProvider + ? { providerThreadId: oldProviderThreadId } + : {}), + status: + input.sideChat.contextRun === "delivered" + ? "completed" + : input.sideChat.contextRun, + completedAt: now, + }, + run, + ], + contextTransfers: + input.sideChat.nativeForkFrom === undefined + ? [] + : [ + { + id: ContextTransferId.make("side-chat-fork"), + type: "fork", + sourceThreadId: parentThreadId, + targetThreadId: threadId, + sourcePoint: { threadId: parentThreadId, runId: parentRunId }, + basePoint: null, + sourceProviderInstanceId: newInstanceId, + targetProviderInstanceId: newInstanceId, + targetRunId: runId, + status: "pending", + resolution: null, + createdBy: "user", + error: null, + createdAt: now, + updatedAt: now, + consumedAt: null, + }, + ], + contextHandoffs: [ + { + id: ContextHandoffId.make("side-chat-context"), + transferId: ContextTransferId.make("side-chat-fork"), + threadId, + targetRunId: input.sideChat.contextRun === "current" ? runId : earlierRunId, + fromProviderThreadIds: [], + toProviderThreadId: input.sideChat.switchedProvider + ? oldProviderThreadId + : providerThreadId, + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: "SIDE_CHAT_CONTEXT", + createdByProviderInstanceId: null, + createdAt: now, + updatedAt: now, + ...(input.sideChat.contextRun === "delivered" + ? { + delivery: { + nativeThreadId: "side-chat-native", + status: "inline" as const, + itemIds: [], + }, + } + : {}), + }, + ], + }; + } if ("historyReadFailureAfterFallback" in input) { const nativeThreadRef = { driver: providerThread.driver, @@ -577,55 +725,78 @@ function makeLocalCommandHarness(input: { ), ensureThread: () => Effect.succeed(providerThread), }; + const providerSession = { + id: providerSessionId, + driver: providerThread.driver, + providerInstanceId: newInstanceId, + status: "ready", + cwd: "/tmp/native-account-command", + model: null, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }; + const startTurn = vi.fn((_turn: ProviderAdapter.ProviderAdapterV2TurnInput) => Effect.void); + const sideChatThread = { + ...providerThread, + nativeThreadRef: { + driver: providerThread.driver, + nativeId: "side-chat-native", + strength: "strong" as const, + }, + }; + const forkThread = vi.fn(() => Effect.succeed(sideChatThread)); + const sideChatSession = { + driver: providerThread.driver, + providerSession, + ensureThread: () => Effect.succeed(sideChatThread), + resumeThread: () => Effect.succeed(sideChatThread), + forkThread, + startTurn, + }; const open = vi.fn(() => - input.interruptOpen === true - ? Effect.interrupt - : "historyReadFailureAfterFallback" in input - ? Effect.succeed(resumeFallbackSession as never) - : "ensureThreadFailure" in input - ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) - : "openFailure" in input - ? Effect.sync(() => { - if (input.interruptRunBeforeOpenFailure === true) interruptRun(); - }).pipe( - Effect.andThen( - Effect.fail( - new ProviderSessionManager.ProviderSessionOpenError({ - instanceId: newInstanceId, - providerSessionId, - cause: input.openFailure, - }), + input.sideChat !== undefined + ? Effect.succeed(sideChatSession as never) + : input.interruptOpen === true + ? Effect.interrupt + : "historyReadFailureAfterFallback" in input + ? Effect.succeed(resumeFallbackSession as never) + : "ensureThreadFailure" in input + ? Effect.succeed({ driver: providerThread.driver, ensureThread } as never) + : "openFailure" in input + ? Effect.sync(() => { + if (input.interruptRunBeforeOpenFailure === true) interruptRun(); + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderSessionManager.ProviderSessionOpenError({ + instanceId: newInstanceId, + providerSessionId, + cause: input.openFailure, + }), + ), ), - ), - ) - : input.failReadsAfterRunning === true || input.previousTurn !== undefined - ? Effect.succeed({ - driver: providerThread.driver, - providerSession: { - id: providerSessionId, + ) + : input.failReadsAfterRunning === true || input.previousTurn !== undefined + ? Effect.succeed({ driver: providerThread.driver, - providerInstanceId: newInstanceId, - status: "ready", - cwd: "/tmp/native-account-command", - model: null, - capabilities: CodexProviderCapabilitiesV2, - createdAt: now, - updatedAt: now, - lastError: null, - }, - ensureThread: () => Effect.succeed(providerThread), - resumeThread: () => Effect.succeed(projection.providerThreads[0]), - } as never) - : Effect.die("A local command must not open a native session."), + providerSession, + ensureThread: () => Effect.succeed(providerThread), + resumeThread: () => Effect.succeed(projection.providerThreads[0]), + } as never) + : Effect.die("A local command must not open a native session."), ); const startRootRun = vi.fn< (input: RunExecutionService.RunExecutionServiceV2StartRootRunInput) => Effect.Effect - >(() => - input.failReadsAfterRunning === true || input.previousTurn !== undefined - ? Effect.suspend(() => - providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, - ) - : Effect.die("A local command must not start a native turn."), + >((runInput) => + input.sideChat !== undefined + ? runInput.session.startTurn(runInput as never).pipe(Effect.orDie) + : input.failReadsAfterRunning === true || input.previousTurn !== undefined + ? Effect.suspend(() => + providerBusy ? Effect.die("The previous provider turn is still running.") : Effect.void, + ) + : Effect.die("A local command must not start a native turn."), ); const failReadIfRunning = Effect.suspend(() => input.failReadsAfterRunning === true && @@ -672,13 +843,16 @@ function makeLocalCommandHarness(input: { ); const resolveRuntimePolicy = vi.fn( ({ thread }: Parameters[0]) => - Effect.succeed({ cwd: thread.worktreePath ?? "/tmp/native-account-command" } as never), + Effect.succeed({ + cwd: thread.worktreePath ?? "/tmp/native-account-command", + runtimeMode: thread.runtimeMode, + } as never), ); const createWorktree = vi.fn(() => Effect.succeed({} as never), ); - const getThreadRecords = vi.fn(() => - Effect.succeed(projection), + const getThreadRecords = vi.fn((id) => + Effect.succeed(id === parentThreadId ? parentRecords : projection), ); const getTurnStartContext = vi.fn(() => Effect.succeed({ @@ -694,9 +868,30 @@ function makeLocalCommandHarness(input: { Layer.provide( Layer.mergeAll( Layer.mock(ContextHandoffService.ContextHandoffServiceV2)({ - prepareProviderHandoff: () => Effect.die("history read must fail first"), + prepareProviderHandoff: (handoff) => + input.sideChat?.nativeForkFrom === undefined + ? Effect.die("history read must fail first") + : Effect.succeed({ + ...handoff, + id: ContextHandoffId.make("side-chat-fork-context"), + createdByProviderInstanceId: handoff.toProviderInstanceId, + status: "ready", + summaryMessageId: null, + summaryText: "PARENT_HISTORY", + updatedAt: handoff.createdAt, + }), + }), + Layer.mock(EventSink.EventSinkV2)({ + writeIfRunCurrent, + write: ({ events: incoming }) => + Effect.sync(() => { + for (const event of incoming) { + events.push(event); + projection = ProjectionStore.applyToProjection(projection, event); + } + return []; + }), }), - Layer.mock(EventSink.EventSinkV2)({ writeIfRunCurrent }), IdAllocator.layer, layerWorktreeLocation(input.worktreesDir), input.fileSystem === undefined @@ -728,12 +923,14 @@ function makeLocalCommandHarness(input: { ), }), getTurnStartHistory: () => - Effect.fail( - new ProjectionStore.ProjectionStoreReadError({ - threadId, - cause: input.historyReadFailureAfterFallback, - }), - ), + input.sideChat !== undefined + ? Effect.succeed([]) + : Effect.fail( + new ProjectionStore.ProjectionStoreReadError({ + threadId, + cause: input.historyReadFailureAfterFallback, + }), + ), }), Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open, @@ -767,6 +964,8 @@ function makeLocalCommandHarness(input: { open, writeIfRunCurrent, startRootRun, + startTurn, + forkThread, interruptTurn, tryHandlePromptCommand, events, @@ -1006,6 +1205,75 @@ effectIt.effect("recovers an abandoned failed turn before continuing its native }), ); +effectIt.effect.each([ + { contextRun: "current", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "failed", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { contextRun: "cancelled", runtimeMode: "approval-required", context: "SIDE_CHAT_CONTEXT" }, + { + contextRun: "failed", + switchedProvider: true, + runtimeMode: "approval-required", + context: "SIDE_CHAT_CONTEXT", + }, + { contextRun: "delivered", runtimeMode: "full-access", context: "" }, +] as const)( + "sends side chat context inline once, for the first turn that reaches the provider: %j", + ({ runtimeMode, context, ...sideChat }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "What is the main thread doing?", + sideChat, + }); + + yield* harness.start; + + // The turn that delivers the context is clamped; a later one follows the thread's mode. + expect(harness.open).toHaveBeenCalledWith( + expect.objectContaining({ runtimePolicy: expect.objectContaining({ runtimeMode }) }), + ); + expect(harness.startTurn.mock.calls.map(([turn]) => turn.message.text)).toEqual([ + withInlineContext(context, "What is the main thread doing?"), + ]); + expect(harness.projection().contextHandoffs).toMatchObject([ + { + id: "side-chat-context", + toProviderThreadId: "new-provider-thread", + delivery: { nativeThreadId: "side-chat-native", status: "inline" }, + }, + ]); + }), +); + +effectIt.effect.each([ + { nativeForkFrom: "idle", forks: true, transferStatus: "consumed", handoffIds: [] }, + { + nativeForkFrom: "busy", + forks: false, + transferStatus: "resolved_portable", + handoffIds: ["side-chat-fork-context"], + }, +] as const)( + "runs a planned native side chat fork when the parent is $nativeForkFrom", + ({ nativeForkFrom, forks, transferStatus, handoffIds }) => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "What is the main thread doing?", + sideChat: { contextRun: "current", nativeForkFrom }, + }); + + yield* harness.start; + + expect(harness.forkThread).toHaveBeenCalledTimes(forks ? 1 : 0); + expect(harness.projection().contextTransfers).toMatchObject([ + { id: "side-chat-fork", status: transferStatus }, + ]); + expect(harness.startTurn.mock.calls[0]?.[0].message.text.includes("PARENT_HISTORY")).toBe( + !forks, + ); + expect(harness.projection().providerThreads[0]?.handoffIds).toEqual(handoffIds); + }), +); + effectIt.effect.each([ { runStatus: "failed", turnStatus: "completed" }, { runStatus: "completed", turnStatus: "completed" }, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 14c93a31ea04..402b2eaf3e04 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -34,6 +34,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts"; import * as EventSink from "./EventSink.ts"; import * as ContextHandoffService from "./ContextHandoffService.ts"; +import { isActiveRun } from "./CommandPolicy.ts"; import { DEFAULT_HANDOFF_TOKEN_CAP, handoffTokenCapConfig, @@ -43,7 +44,7 @@ import { historicalMessage, latestNativeContextUsage, } from "@t3tools/provider-core/server/handoffBudget"; -import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; +import { deliverContextHandoffs, withInlineContext } from "./ContextHandoffDelivery.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; @@ -363,27 +364,40 @@ export const layer: Layer.Layer< .filter((transfer) => transfer.type === "subagent_result") .map((transfer) => transfer.id), ); + const isPendingForRun = (handoff: OrchestrationV2ContextHandoff, anyProviderThread = false) => + handoff.status === "ready" && + (handoff.targetRunId === run.id || + ((anyProviderThread || handoff.toProviderThreadId === run.providerThreadId) && + projection.runs.some( + (source) => + source.id === handoff.targetRunId && + (source.status === "failed" || + source.status === "interrupted" || + source.status === "cancelled" || + (source.status === "completed" && + handoff.delivery === undefined && + // Compaction and /goal turns defer history to the next turn. + projection.messages.some( + (message) => + message.id === source.userMessageId && + (isCompactionCommandMessage(message) || parseGoalCommand(message) !== null), + ))), + ))); + const isSideChatHandoff = (handoff: OrchestrationV2ContextHandoff) => + projection.thread.lineage.sideChat === true && + ContextHandoffService.isSideChatContextHandoff(handoff); const handoffs = projection.contextHandoffs.filter( (handoff) => + !isSideChatHandoff(handoff) && (handoff.transferId == null || !subagentResultTransfers.has(handoff.transferId)) && - handoff.status === "ready" && - (handoff.targetRunId === run.id || - (handoff.toProviderThreadId === run.providerThreadId && - projection.runs.some( - (source) => - source.id === handoff.targetRunId && - (source.status === "failed" || - source.status === "interrupted" || - (source.status === "completed" && - handoff.delivery === undefined && - // Compaction and /goal turns defer history to the next turn. - projection.messages.some( - (message) => - message.id === source.userMessageId && - (isCompactionCommandMessage(message) || - parseGoalCommand(message) !== null), - ))), - ))), + isPendingForRun(handoff), + ); + // Carried forward until a provider turn accepts it, also after a provider switch. + const sideChatHandoff = projection.contextHandoffs.find( + (handoff) => + isSideChatHandoff(handoff) && + handoff.delivery === undefined && + isPendingForRun(handoff, true), ); const nativeForkTransfer = projection.contextTransfers.find( (transfer) => @@ -675,8 +689,14 @@ export const layer: Layer.Layer< }); const { isCurrentAttemptInStatus } = runControls; + // A side chat shares its parent's worktree; the turn that gets its context asks before acting. const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ - thread: projection.thread, + thread: + projection.thread.lineage.sideChat === true && + (sideChatHandoff !== undefined || + !projection.runs.some((previous) => previous.ordinal < run.ordinal)) + ? { ...projection.thread, runtimeMode: "approval-required" } + : projection.thread, modelSelection: run.modelSelection, }); if (!(yield* isCurrentAttemptInStatus("starting"))) return; @@ -1072,6 +1092,7 @@ export const layer: Layer.Layer< }; let resumedRetryHandoff: ReadonlyArray | undefined; let resumedHandoffContext: ReturnType | undefined; + let portableForkFallback: OrchestrationV2ContextHandoff | undefined; const loadedProviderThread = yield* Effect.gen(function* () { if (nativeForkTransfer !== undefined) { const sourceProjection = yield* projectionStore.getThreadRecords( @@ -1098,18 +1119,91 @@ export const layer: Layer.Layer< cause: `Native fork transfer ${nativeForkTransfer.id} has no source provider execution.`, }); } - return yield* loadFromProvider( - session.forkThread({ - sourceProviderThread, - sourceProviderTurns: sourceProjection.providerTurns, - targetThreadId: projection.thread.id, + // Providers refuse to fork a busy thread, so a source that started a turn after + // dispatch planned this fork gets the portable context dispatch gives a busy source. + const sourceBusy = sourceProjection.runs.some( + (candidate) => candidate.ordinal > sourceRun.ordinal && isActiveRun(candidate), + ); + if (!sourceBusy) { + return yield* loadFromProvider( + session.forkThread({ + sourceProviderThread, + sourceProviderTurns: sourceProjection.providerTurns, + targetThreadId: projection.thread.id, + modelSelection: run.modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(sourceProviderTurn === undefined + ? {} + : { providerTurnId: sourceProviderTurn.id }), + }), + ); + } + const sourceItems = yield* ContextHandoffService.readForkHistoryItems( + projectionStore, + sourceProjection, + sourceRun, + ); + const createdAt = yield* DateTime.now; + const handoff = yield* contextHandoffService.prepareProviderHandoff({ + threadId: projection.thread.id, + targetRunId: run.id, + transferId: nativeForkTransfer.id, + fromProviderThreadIds: [sourceProviderThread.id], + toProviderThreadId: providerThread.id, + fromProviderInstanceId: sourceRun.providerInstanceId, + toProviderInstanceId: run.providerInstanceId, + coveredRunOrdinals: ContextHandoffService.visibleDeltaRunOrdinals( + sourceProjection, + sourceItems, + ), + strategy: "full_thread_summary", + items: sourceItems, + runs: sourceProjection.runs, + createdAt, + }); + const loaded = yield* loadFromProvider( + session.ensureThread({ + threadId: projection.thread.id, + title: projection.thread.title, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, - ...(sourceProviderTurn === undefined - ? {} - : { providerTurnId: sourceProviderTurn.id }), + providerSessionId, + existingProviderThread: { ...providerThread, forkedFrom: null }, }), ); + if (loaded === undefined) return undefined; + portableForkFallback = handoff; + effectiveHandoffs = [handoff, ...effectiveHandoffs]; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: handoff, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "context-transfer.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: createdAt, + payload: { + ...nativeForkTransfer, + targetProviderInstanceId: run.providerInstanceId, + status: "resolved_portable", + resolution: { strategy: "portable_context", contextHandoffId: handoff.id }, + error: null, + updatedAt: createdAt, + }, + }, + ], + }); + return loaded; } if (providerThread.nativeThreadRef === null) { // Pass the stored provider row for adoption; adapter-minted duplicates make activeProviderThreadId flap. @@ -1299,8 +1393,11 @@ export const layer: Layer.Layer< ownerNodeId: providerThread.ownerNodeId, firstRunOrdinal: providerThread.firstRunOrdinal ?? run.ordinal, lastRunOrdinal: run.ordinal, - handoffIds: providerThread.handoffIds, - forkedFrom: providerThread.forkedFrom, + handoffIds: + portableForkFallback === undefined + ? providerThread.handoffIds + : [...providerThread.handoffIds, portableForkFallback.id], + forkedFrom: portableForkFallback === undefined ? providerThread.forkedFrom : null, status: "active", createdAt: providerThread.createdAt, updatedAt: now, @@ -1345,7 +1442,9 @@ export const layer: Layer.Layer< occurredAt: now, payload: runningProviderThread, }, - ...(nativeForkTransfer === undefined || runningProviderThread.nativeThreadRef === null + ...(nativeForkTransfer === undefined || + portableForkFallback !== undefined || + runningProviderThread.nativeThreadRef === null ? [] : [ { @@ -1415,6 +1514,26 @@ export const layer: Layer.Layer< const routableSubagents = projection.subagents.filter((subagent) => RunExecutionService.canRouteRelatedSubagent(subagent.status), ); + const persistHandoff = Effect.fn("ProviderTurnStartService.persist")(function* ( + handoff: OrchestrationV2ContextHandoff, + ) { + const updatedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ + threadId: projection.thread.id, + }), + type: "context-handoff.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: updatedAt, + payload: { ...handoff, updatedAt }, + }, + ], + }); + }); const startWithHandoffs = Effect.fn("ProviderTurnStartService.startWithHandoffs")( function* (turnInput: Parameters[0], compact = false) { const delivery = yield* deliverContextHandoffs({ @@ -1432,28 +1551,16 @@ export const layer: Layer.Layer< ...history, }), }), - persist: Effect.fn("ProviderTurnStartService.persist")(function* (handoff) { - const updatedAt = yield* DateTime.now; - yield* eventSink.write({ - events: [ - { - id: yield* idAllocator.allocate.event({ - threadId: projection.thread.id, - }), - type: "context-handoff.updated", - threadId: projection.thread.id, - runId: run.id, - providerInstanceId: run.providerInstanceId, - occurredAt: updatedAt, - payload: { ...handoff, updatedAt }, - }, - ], - }); - }), + persist: persistHandoff, }); if (!(yield* isCurrentAttemptInStatus("running"))) return; const start = compact ? session.compactThread! : session.startTurn; - const context = [delivery.context, restartNote] + // Like history, the side chat context waits for a turn that sends a prompt. + const sideChatContext = + sideChatHandoff === undefined || compact || goalCommand + ? "" + : sideChatHandoff.summaryText; + const context = [delivery.context, sideChatContext, restartNote] .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; @@ -1461,10 +1568,7 @@ export const layer: Layer.Layer< ...(noteContinuation ? promptedInput : turnInput), message: { ...turnInput.message, - text: - context === "" || goalCommand - ? userText - : `${context}\n\nUser message:\n${userText}`, + text: goalCommand ? userText : withInlineContext(context, userText), }, }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. @@ -1481,13 +1585,30 @@ export const layer: Layer.Layer< ), ); // After provider acceptance, failed delivery bookkeeping must not stop live ingestion. - yield* delivery.delivered.pipe( - Effect.catchCause(() => - Effect.logWarning("Failed to record accepted context handoff delivery", { - runId: run.id, - deliveryStatus: "pending", - }), - ), + const nativeThreadId = runningProviderThread.nativeThreadRef?.nativeId; + yield* Effect.forEach( + [ + delivery.delivered, + ...(sideChatContext === "" || sideChatHandoff === undefined || nativeThreadId == null + ? [] + : [ + persistHandoff({ + ...sideChatHandoff, + toProviderThreadId: runningProviderThread.id, + delivery: { nativeThreadId, status: "inline" as const, itemIds: [] }, + }), + ]), + ], + (record) => + record.pipe( + Effect.catchCause(() => + Effect.logWarning("Failed to record accepted context handoff delivery", { + runId: run.id, + deliveryStatus: "pending", + }), + ), + ), + { discard: true }, ); }, Effect.mapError((cause) => @@ -1505,6 +1626,7 @@ export const layer: Layer.Layer< const deliverySession = effectiveHandoffs.length === 0 && retryHandoff.length === 0 && + sideChatHandoff === undefined && restartNote === "" && !noteContinuation ? session diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index 22d5b8b879a6..b9b2b09bd227 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -920,21 +920,24 @@ export const layer: Layer.Layer< .responseStreamingMode, ), ); - yield* checkpointService - .captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - .pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (_cause) => - Effect.logWarning( - "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", - { runId: input.run.id }, - ), - ), - ); + // Side chats share the parent's worktree; a snapshot would capture the parent's edits. + yield* ( + input.appThread.lineage.sideChat === true + ? Effect.void + : checkpointService.captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + ).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (_cause) => + Effect.logWarning( + "orchestration V2 checkpoint baseline capture failed; starting provider without a baseline", + { runId: input.run.id }, + ), + ), + ); if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) diff --git a/apps/server/src/orchestration-v2/ShellStream.test.ts b/apps/server/src/orchestration-v2/ShellStream.test.ts index e308c74a00ff..336f9b12f96b 100644 --- a/apps/server/src/orchestration-v2/ShellStream.test.ts +++ b/apps/server/src/orchestration-v2/ShellStream.test.ts @@ -235,9 +235,24 @@ describe("omitOlderSettledShells", () => { omitOlderSettledShells({ threads, snapshotSequence: 7, now }); it("keeps the 25 newest settled threads and counts the rest per project", () => { - const result = omit([...newest, row("old", 30), row("b-old", 40), row("unsettled", null)]); + const sideChat = row("side-chat", 0, { + lineage: { + parentThreadId: ThreadId.make("new-0"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("new-0"), + sideChat: true, + }, + }); + const result = omit([ + ...newest, + sideChat, + row("old", 30), + row("b-old", 40), + row("unsettled", null), + ]); expect(result.threads.map((thread) => thread.id)).toEqual([ ...newest.map((thread) => thread.id), + "side-chat", "unsettled", ]); expect(result.olderSettled).toEqual({ @@ -262,7 +277,7 @@ describe("omitOlderSettledShells", () => { expect(result.olderSettled.counts).toEqual([]); }); - it("keeps older rows that are snoozed, live, or above a kept subagent, and still counts them", () => { + it("keeps older rows that are snoozed, live, or above a kept subagent or side chat, and still counts them", () => { const threads = [ ...newest, row("snoozed", 30, { snoozedUntil: DateTime.makeUnsafe(101 * DAY) }), @@ -280,6 +295,15 @@ describe("omitOlderSettledShells", () => { settledOverride: null, settledAt: null, }), + row("side-chat-parent", 30), + row("side-chat", null, { + lineage: { + parentThreadId: ThreadId.make("side-chat-parent"), + relationshipToParent: "fork", + rootThreadId: ThreadId.make("side-chat-parent"), + sideChat: true, + }, + }), ]; const result = omit(threads); expect(result.threads.map((thread) => thread.id).slice(25)).toEqual([ @@ -287,8 +311,10 @@ describe("omitOlderSettledShells", () => { "running", "parent", "child", + "side-chat-parent", + "side-chat", ]); - expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 4 }]); + expect(result.olderSettled.counts).toEqual([{ projectId: "project-a", count: 5 }]); }); }); diff --git a/apps/server/src/orchestration-v2/ShellStream.ts b/apps/server/src/orchestration-v2/ShellStream.ts index 67d07d6684c0..0fa5251a7ef7 100644 --- a/apps/server/src/orchestration-v2/ShellStream.ts +++ b/apps/server/src/orchestration-v2/ShellStream.ts @@ -12,6 +12,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { + isListedThreadLineage, isLiveSubagentShell, isOlderSettledThreadShell, OrchestrationProjectShell as ProjectShellSchema, @@ -98,7 +99,11 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { if (row.lineage.relationshipToParent === "subagent") { subagentThreadIds.add(row.id); if (row.lineage.parentThreadId) subagentParentIds.add(row.lineage.parentThreadId); - } else if (row.settledOverride === "settled" && row.settledAt !== null) { + } else if ( + isListedThreadLineage(row.lineage) && + row.settledOverride === "settled" && + row.settledAt !== null + ) { settledTimes.push(DateTime.toEpochMillis(row.settledAt)); } } @@ -121,10 +126,12 @@ function shellTrimFacts(rows: ReadonlyArray): ShellTrimFacts { }; } -function subagentAncestorIds(threads: ReadonlyArray>) { +function hiddenChildAncestorIds( + threads: ReadonlyArray>, +) { const ancestorIds = new Set(); for (const thread of threads) { - if (thread.lineage.relationshipToParent !== "subagent") continue; + if (isListedThreadLineage(thread.lineage)) continue; if (thread.lineage.parentThreadId !== null) ancestorIds.add(thread.lineage.parentThreadId); ancestorIds.add(thread.lineage.rootThreadId); } @@ -156,7 +163,7 @@ function omitOlderSettledShellsWith( } { const nowMs = DateTime.toEpochMillis(input.now); const beforeMs = facts.olderSettledBeforeMs ?? nowMs; - const ancestorIds = subagentAncestorIds(input.threads); + const ancestorIds = hiddenChildAncestorIds(input.threads); const threads = input.threads.filter( (thread) => !isOlderSettledThreadShell(thread, beforeMs) || @@ -207,7 +214,8 @@ export function activeShellRowsForSubscriber( /** * Opted-in snapshots keep the 25 newest settled top-level threads (and ties) and count the rest per * project in `olderSettled`; clients page them with `getOlderSettledThreads`. Older rows stay while - * pinned, snoozed, live, or above a kept subagent, so walks over shell rows still find them. + * pinned, snoozed, live, or above a kept subagent or side chat, so walks over shell rows still find + * them. */ export function omitOlderSettledShells(input: { readonly threads: ReadonlyArray; @@ -255,7 +263,10 @@ function shellSubscriberThreadIds( entry.lineage.relationshipToParent === "subagent" && (!options.omitIdleSubagents || entry.mayBeLive || facts.subagentParentIds.has(entry.id)), ); - const ancestorIds = subagentAncestorIds(subagents); + const ancestorIds = hiddenChildAncestorIds([ + ...subagents, + ...index.filter((entry) => entry.lineage.sideChat === true), + ]); const keptSubagentIds = new Set(subagents.map((entry) => entry.id)); const beforeMs = facts.olderSettledBeforeMs; return index.flatMap((entry) => diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 1015e44694c8..d4867a264ec9 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -1547,3 +1547,82 @@ it.effect("stops the running turn when a goal clear ends the goal driving it", ( }), ), ); + +it.effect("a side chat that opens with /goal gets its context on the next question", () => + Effect.scoped( + Effect.gen(function* () { + const { events, layer, started, startFirstTurn } = yield* goalHarness("side-chat-goal"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const parentId = yield* startFirstTurn; + const sideChatId = ThreadId.make("thread:side-chat-goal-child"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + const ask = (id: string, text: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId: sideChatId, + messageId: MessageId.make(`message:${id}`), + text, + attachments: [], + modelSelection: runSelection, + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const sideChatEvent = (matches: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter((event) => event.threadId === sideChatId && matches(event)), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ); + const running = yield* sideChatEvent( + (event) => event.type === "provider-turn.updated" && event.payload.status === "running", + ); + const settled = yield* sideChatEvent( + (event) => event.type === "run.updated" && event.payload.status === "waiting", + ); + yield* ask("goal", "/goal clear"); + yield* worker.drain(); + yield* Fiber.join(running); + const turn = (yield* orchestrator.getThreadProjection(sideChatId)).providerTurns[0]!; + yield* Queue.offer(events, { + type: "provider_turn.updated", + driver, + providerTurn: { ...turn, status: "completed", completedAt: yield* DateTime.now }, + }); + yield* Queue.offer(events, { + type: "turn.terminal", + driver, + providerThreadId: turn.providerThreadId, + providerTurnId: turn.id, + runOrdinal: started[1]!.runOrdinal, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + yield* Fiber.join(settled); + yield* worker.drain(); + yield* ask("question", "What is the main thread doing?"); + yield* worker.drain(); + + const [first, goal, question] = started.map((turn) => turn.message.text); + assert.deepEqual([first, goal], ["first", "/goal clear"]); + assert.include(question, "You are in a side chat beside the main thread."); + assert.include(question, "- User: first"); + assert.isTrue(question?.endsWith("What is the main thread doing?")); + }).pipe(Effect.provide(layer)); + }), + ), +); diff --git a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts index 3350d31fb708..71d40332f2d7 100644 --- a/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFork.execution.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + CheckpointId, CommandId, EventId, MessageId, @@ -214,13 +215,15 @@ it.effect.each(forkCases)( const attemptId = RunAttemptId.make("interrupted-source-attempt"); const providerTurnId = ProviderTurnId.make("interrupted-source-turn"); const rootNodeId = NodeId.make("interrupted-source-root"); + const grandparentThreadId = ThreadId.make("fork-boundary-grandparent"); + const grandparentRunId = RunId.make("fork-boundary-grandparent-run"); yield* orchestrator.dispatch({ type: "thread.create", - commandId: CommandId.make("create-source"), - threadId: sourceThreadId, + commandId: CommandId.make("create-grandparent"), + threadId: grandparentThreadId, projectId: ProjectId.make("fork-boundary-project"), - title: "Fork boundary source", + title: "Fork boundary grandparent", modelSelection, runtimeMode: "full-access", interactionMode: "default", @@ -229,6 +232,29 @@ it.effect.each(forkCases)( createdBy: "user", creationSource: "web", }); + yield* writeParentRun({ + threadId: grandparentThreadId, + runId: RunId.make("fork-boundary-grandparent-rolled-back-run"), + ordinal: 1, + status: "rolled_back", + text: "ROLLED_BACK_MARKER", + }); + yield* writeParentRun({ + threadId: grandparentThreadId, + runId: grandparentRunId, + ordinal: 2, + status: "completed", + text: "GRANDPARENT_MARKER", + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork-grandparent"), + sourceThreadId: grandparentThreadId, + targetThreadId: sourceThreadId, + sourcePoint: { type: "run", runId: grandparentRunId }, + createdBy: "user", + creationSource: "web", + }); yield* eventSink.write({ events: [ { @@ -246,8 +272,8 @@ it.effect.each(forkCases)( nativeThreadRef: { driver, nativeId: "native-source", strength: "strong" }, nativeConversationHeadRef: null, status: "idle", - firstRunOrdinal: 1, - lastRunOrdinal: 2, + firstRunOrdinal: 2, + lastRunOrdinal: 3, handoffIds: [], forkedFrom: null, createdAt: now, @@ -299,8 +325,15 @@ it.effect.each(forkCases)( ], }); } - for (const ordinal of [1, 2]) { - const runId = ordinal === 1 ? sourceRunId : RunId.make("later-run"); + yield* writeParentRun({ + threadId: sourceThreadId, + runId: RunId.make("fork-boundary-source-rolled-back-run"), + ordinal: 1, + status: "rolled_back", + text: "SOURCE_ROLLED_BACK_MARKER", + }); + for (const ordinal of [2, 3]) { + const runId = ordinal === 2 ? sourceRunId : RunId.make("later-run"); const messageId = MessageId.make(`source-message-${ordinal}`); yield* eventSink.write({ events: [ @@ -319,8 +352,8 @@ it.effect.each(forkCases)( providerThreadId, userMessageId: messageId, rootNodeId: null, - activeAttemptId: ordinal === 1 && status === "interrupted" ? attemptId : null, - status: ordinal === 1 ? status : "completed", + activeAttemptId: ordinal === 2 && status === "interrupted" ? attemptId : null, + status: ordinal === 2 ? status : "completed", queuePosition: null, requestedAt: now, startedAt: now, @@ -355,7 +388,7 @@ it.effect.each(forkCases)( creationSource: "web", inputIntent: "turn_start", messageId, - text: ordinal === 1 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", + text: ordinal === 2 ? "INCLUDED_SOURCE_MARKER" : "EXCLUDED_LATER_MARKER", attachments: [], }, }, @@ -388,6 +421,9 @@ it.effect.each(forkCases)( assert.lengthOf(target.contextHandoffs, 1); const handoff = target.contextHandoffs[0]!; const history = handoff.history?.messages.map((message) => message.text).join("\n") ?? ""; + assert.include(`${handoff.summaryText}\n${history}`, "GRANDPARENT_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "SOURCE_ROLLED_BACK_MARKER"); + assert.notInclude(`${handoff.summaryText}\n${history}`, "ROLLED_BACK_MARKER"); assert.include(`${handoff.summaryText}\n${history}`, "INCLUDED_SOURCE_MARKER"); assert.notInclude(`${handoff.summaryText}\n${history}`, "EXCLUDED_LATER_MARKER"); assert.isNull(target.providerThreads[0]?.forkedFrom); @@ -607,3 +643,561 @@ it.effect.each([ ); }, ); + +const writeParentRun = (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly ordinal: number; + /** Preparing, failed, and interrupted runs asked for a new worktree. */ + readonly status: + | "preparing" + | "running" + | "queued" + | "completed" + | "rolled_back" + | "failed" + | "interrupted"; + readonly text: string; + /** A checkpointed run on a native provider thread, which `latest_stable` forks start from. */ + readonly stable?: boolean; +}) => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { driver, instanceId, modelSelection } = forkCases[0]!; + const messageId = MessageId.make(`${input.runId}:message`); + const providerThreadId = input.stable + ? ProviderThreadId.make(`${input.runId}:provider-thread`) + : null; + yield* eventSink.write({ + events: [ + ...(providerThreadId === null + ? [] + : [ + { + id: EventId.make(`${input.runId}:provider-thread`), + type: "provider-thread.updated" as const, + threadId: input.threadId, + occurredAt: now, + payload: { + id: providerThreadId, + driver, + providerInstanceId: instanceId, + providerSessionId: null, + appThreadId: input.threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: `${input.runId}:native`, + strength: "strong" as const, + }, + nativeConversationHeadRef: null, + status: "idle" as const, + firstRunOrdinal: input.ordinal, + lastRunOrdinal: input.ordinal, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }, + }, + ]), + { + id: EventId.make(`${input.runId}:created`), + type: "run.created", + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: input.runId, + threadId: input.threadId, + ordinal: input.ordinal, + providerInstanceId: instanceId, + modelSelection, + providerThreadId, + userMessageId: messageId, + rootNodeId: null, + activeAttemptId: null, + status: input.status, + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: input.status === "completed" ? now : null, + checkpointId: input.stable ? CheckpointId.make(`${input.runId}:checkpoint`) : null, + contextHandoffId: null, + ...(input.status === "preparing" || + input.status === "failed" || + input.status === "interrupted" + ? { workspacePreparation: { type: "worktree" as const, baseRef: "main" } } + : {}), + }, + }, + { + id: EventId.make(`${input.runId}:item`), + type: "turn-item.updated", + threadId: input.threadId, + runId: input.runId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${input.runId}:item`), + threadId: input.threadId, + runId: input.runId, + nodeId: null, + providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: input.ordinal * 100, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId, + text: input.text, + attachments: [], + }, + }, + ], + }); + }); + +/** Marks `threadId` as imported from V1 and gives it one runless imported user message. */ +const writeImportedHistory = (threadId: ThreadId, text: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const { thread } = yield* orchestrator.getThreadProjection(threadId); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:imported`), + type: "thread.metadata-updated", + threadId, + occurredAt: now, + payload: { ...thread, historyOrigin: "v1_import" }, + }, + { + id: EventId.make(`${threadId}:imported-item`), + type: "turn-item.updated", + threadId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:imported-item`), + threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: null, + startedAt: now, + completedAt: now, + updatedAt: now, + type: "user_message", + createdBy: "user", + creationSource: "web", + inputIntent: "turn_start", + messageId: MessageId.make(`${threadId}:imported-message`), + text, + attachments: [], + }, + }, + ], + }); + }); + +const createParent = (threadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + projectId: ProjectId.make("side-chat-project"), + title: "Side chat parent", + modelSelection: forkCases[0]!.modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + }); + +const forkSideChat = (commandId: string, sourceThreadId: ThreadId, targetThreadId: ThreadId) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + return yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(commandId), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + }); + +const askSideChat = (threadId: ThreadId, commandId: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(commandId), + threadId, + messageId: MessageId.make(commandId), + text: "What is the main thread doing?", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + }); + +it.effect("a side chat of an idle parent still starts with the framing note", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("side-chat-idle-parent"); + const sideChatId = ThreadId.make("side-chat-idle-child"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-idle-parent-run"), + ordinal: 1, + status: "completed", + text: "Parent work", + stable: true, + }); + + yield* forkSideChat("side-chat-idle-fork", parentId, sideChatId); + yield* askSideChat(sideChatId, "side-chat-idle-first-message"); + + const started = yield* orchestrator.getThreadProjection(sideChatId); + assert.isNotNull(started.providerThreads[0]?.forkedFrom); + assert.lengthOf(started.contextHandoffs, 1); + const [handoff] = started.contextHandoffs; + assert.isTrue( + handoff!.summaryText.startsWith("You are in a side chat beside the main thread."), + ); + assert.notInclude(handoff!.summaryText, "Main thread activity"); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("a side chat waits for the parent's new worktree, also after its setup ended", () => + Effect.gen(function* () { + for (const status of ["preparing", "failed", "interrupted"] as const) { + const parentId = ThreadId.make(`side-chat-worktree-parent-${status}`); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make(`side-chat-worktree-parent-run-${status}`), + ordinal: 1, + status, + text: "Parent work", + }); + + const rejected = yield* forkSideChat( + `side-chat-worktree-fork-${status}`, + parentId, + ThreadId.make(`side-chat-worktree-child-${status}`), + ).pipe(Effect.flip); + assert.include(String(rejected.cause), "has no worktree yet", status); + } + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("closing a side chat deletes it only while unused; a deleted parent takes no more", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("side-chat-close-parent"); + const unused = ThreadId.make("side-chat-close-unused"); + const used = ThreadId.make("side-chat-close-used"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-close-parent-run"), + ordinal: 1, + status: "completed", + text: "Parent work", + stable: true, + }); + yield* forkSideChat("side-chat-close-fork-unused", parentId, unused); + yield* forkSideChat("side-chat-close-fork-used", parentId, used); + yield* askSideChat(used, "side-chat-close-used-message"); + const close = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`side-chat-close:${threadId}`), + threadId, + onlyIfUnused: true, + }); + + yield* close(unused); + yield* close(used); + assert.isNotNull((yield* orchestrator.getThreadProjection(unused)).thread.deletedAt); + assert.isNull((yield* orchestrator.getThreadProjection(used)).thread.deletedAt); + + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("side-chat-close-delete-parent"), + threadId: parentId, + }); + assert.isNotNull((yield* orchestrator.getThreadProjection(used)).thread.deletedAt); + const orphan = yield* forkSideChat( + "side-chat-close-orphan", + parentId, + ThreadId.make("side-chat-close-orphan"), + ).pipe(Effect.flip); + assert.include(String(orphan.cause), "was deleted"); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("a side chat opened during the parent's first turn starts from a digest", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const grandparentId = ThreadId.make("side-chat-busy-grandparent"); + const grandparentRunId = RunId.make("side-chat-busy-grandparent-run"); + const parentId = ThreadId.make("side-chat-busy-parent"); + const sideChatId = ThreadId.make("side-chat-busy-child"); + yield* createParent(grandparentId); + yield* writeParentRun({ + threadId: grandparentId, + runId: grandparentRunId, + ordinal: 1, + status: "completed", + text: "GRANDPARENT_MARKER", + stable: true, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat-busy-parent-fork"), + sourceThreadId: grandparentId, + targetThreadId: parentId, + sourcePoint: { type: "run", runId: grandparentRunId }, + createdBy: "user", + creationSource: "web", + }); + yield* writeImportedHistory(parentId, "PARENT_IMPORTED_MARKER"); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-rewound-parent-run"), + ordinal: 1, + status: "rolled_back", + text: "PARENT_ROLLED_BACK_MARKER", + }); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-busy-parent-run"), + ordinal: 2, + status: "running", + text: "PARENT_RUNNING_MARKER", + }); + yield* writeParentRun({ + threadId: parentId, + runId: RunId.make("side-chat-queued-parent-run"), + ordinal: 3, + status: "queued", + text: "PARENT_QUEUED_MARKER", + }); + const fork = (commandId: string, sourceThreadId: ThreadId, targetThreadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(commandId), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + yield* fork("side-chat-busy-fork", parentId, sideChatId); + const created = yield* orchestrator.getThreadProjection(sideChatId); + assert.isTrue(created.thread.lineage.sideChat); + assert.equal(created.thread.runtimeMode, "approval-required"); + assert.isNull(created.thread.forkedFrom); + assert.deepEqual(created.contextTransfers[0]?.sourcePoint, { threadId: parentId }); + + const nested = yield* fork( + "side-chat-busy-nested", + sideChatId, + ThreadId.make("side-chat-busy-nested"), + ).pipe(Effect.flip); + assert.equal(nested._tag, "OrchestratorDispatchError"); + // Either would take the side chat's lock before the parent's, against the delete cascade. + const ontoParent = yield* fork("side-chat-busy-onto-parent", sideChatId, parentId).pipe( + Effect.flip, + ); + assert.equal(ontoParent._tag, "OrchestratorThreadAlreadyExistsError"); + const ontoExisting = yield* fork("side-chat-busy-onto-existing", parentId, sideChatId).pipe( + Effect.flip, + ); + assert.equal(ontoExisting._tag, "OrchestratorThreadAlreadyExistsError"); + const plainOntoExisting = yield* orchestrator + .dispatch({ + type: "thread.fork", + commandId: CommandId.make("side-chat-busy-plain-onto-existing"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip); + assert.equal(plainOntoExisting._tag, "OrchestratorThreadAlreadyExistsError"); + const busyMergeBack = yield* orchestrator + .dispatch({ + type: "thread.merge_back", + commandId: CommandId.make("side-chat-busy-merge-back"), + sourceThreadId: sideChatId, + targetThreadId: parentId, + sourcePoint: { type: "latest_stable" }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip); + assert.include(String(busyMergeBack.cause), "active work"); + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("side-chat-busy-first-message"), + threadId: sideChatId, + messageId: MessageId.make("side-chat-busy-first-message"), + text: "What is the main thread doing?", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const started = yield* orchestrator.getThreadProjection(sideChatId); + const [handoff] = started.contextHandoffs; + assert.lengthOf(started.contextHandoffs, 1); + assert.deepEqual(started.contextTransfers[0]?.resolution, { + strategy: "portable_context", + contextHandoffId: handoff!.id, + }); + assert.include(handoff!.summaryText, "You are in a side chat beside the main thread."); + // Inherited rows come first, so the cap truncates them first. + assert.isAbove( + handoff!.summaryText.indexOf("- User: PARENT_IMPORTED_MARKER"), + handoff!.summaryText.indexOf("- User: GRANDPARENT_MARKER"), + ); + assert.include(handoff!.summaryText, "- User: GRANDPARENT_MARKER"); + assert.notInclude(handoff!.summaryText, "PARENT_ROLLED_BACK_MARKER"); + assert.notInclude(handoff!.summaryText, "PARENT_QUEUED_MARKER"); + assert.include(handoff!.summaryText, "- User: PARENT_IMPORTED_MARKER"); + assert.include(handoff!.summaryText, "- User: PARENT_RUNNING_MARKER"); + assert.include(handoff!.summaryText, `t3_thread_read on thread ${parentId}`); + assert.equal( + started.messages.find((message) => message.role === "user")?.text, + "What is the main thread doing?", + ); + + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make("side-chat-busy-delete-parent"), + threadId: parentId, + }); + assert.isNotNull((yield* orchestrator.getThreadProjection(sideChatId)).thread.deletedAt); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); + +it.effect("merge-back rejects a second fork's context until the first is consumed", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const parentId = ThreadId.make("merge-back-guard-parent"); + const parentRunId = RunId.make("merge-back-guard-parent-run"); + yield* createParent(parentId); + yield* writeParentRun({ + threadId: parentId, + runId: parentRunId, + ordinal: 1, + status: "completed", + text: "Parent work", + }); + const forkAndRun = (name: string) => + Effect.gen(function* () { + const threadId = ThreadId.make(`merge-back-guard-${name}`); + const runId = RunId.make(`merge-back-guard-${name}-run`); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`merge-back-guard-fork-${name}`), + sourceThreadId: parentId, + targetThreadId: threadId, + sourcePoint: { type: "run", runId: parentRunId }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + yield* writeParentRun({ threadId, runId, ordinal: 1, status: "completed", text: name }); + return { threadId, runId }; + }); + const mergeBack = (commandId: string, fork: { threadId: ThreadId; runId: RunId }) => + orchestrator.dispatch({ + type: "thread.merge_back", + commandId: CommandId.make(commandId), + sourceThreadId: fork.threadId, + targetThreadId: parentId, + sourcePoint: { type: "run", runId: fork.runId }, + createdBy: "user", + creationSource: "web", + }); + const first = yield* forkAndRun("first"); + const second = yield* forkAndRun("second"); + + yield* mergeBack("merge-back-guard-first", first); + const rejected = yield* mergeBack("merge-back-guard-second", second).pipe(Effect.flip); + assert.include(String(rejected.cause), "pending context from another side chat or fork"); + yield* mergeBack("merge-back-guard-first-again", first); + // That pending merge-back targets the parent, so it does not block one into `first`. + const nested = { threadId: ThreadId.make("merge-back-guard-nested") }; + const nestedRunId = RunId.make("merge-back-guard-nested-run"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("merge-back-guard-fork-nested"), + sourceThreadId: first.threadId, + targetThreadId: nested.threadId, + sourcePoint: { type: "run", runId: first.runId }, + createdBy: "user", + creationSource: "web", + }); + yield* writeParentRun({ + threadId: nested.threadId, + runId: nestedRunId, + ordinal: 1, + status: "completed", + text: "nested", + }); + yield* orchestrator.dispatch({ + type: "thread.merge_back", + commandId: CommandId.make("merge-back-guard-nested"), + sourceThreadId: nested.threadId, + targetThreadId: first.threadId, + sourcePoint: { type: "run", runId: nestedRunId }, + createdBy: "user", + creationSource: "web", + }); + + const parent = yield* orchestrator.getThreadProjection(parentId); + assert.deepEqual( + parent.contextTransfers + .filter((transfer) => transfer.type === "merge_back") + .map((transfer) => [transfer.sourceThreadId, transfer.status]), + [ + [first.threadId, "superseded"], + [first.threadId, "pending"], + ], + ); + }).pipe(Effect.provide(forkCases[0]!.layer)), +); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index e1bf75dd1284..6d401d968491 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import { CommandId, ContextTransferId, + EnvironmentId, MessageId, type ModelSelection, type OrchestrationV2AppThread, @@ -111,6 +112,7 @@ function makeSourceProjection(sourceRun: OrchestrationV2Run): OrchestrationV2Thr const planFork = ( sourceRun: OrchestrationV2Run, sourceProjection = makeSourceProjection(sourceRun), + options: { readonly sideChat?: boolean } = {}, ) => Effect.gen(function* () { const service = yield* ThreadForkService.ThreadForkServiceV2; @@ -125,6 +127,7 @@ const planFork = ( transferId: ContextTransferId.make("context-transfer:fork-snoozed-source"), targetThreadId, title: "Awake fork", + ...options, createdBy: "user", creationSource: "mobile", createdAt: forkCreatedAt, @@ -261,3 +264,63 @@ it.effect("rejects in-progress and rolled-back fork sources", () => } }), ); + +it.effect("marks only requested forks as supervised side chats", () => + Effect.gen(function* () { + const sourceRun = makeSourceRun("completed"); + const sideChat = yield* planFork(sourceRun, makeSourceProjection(sourceRun), { + sideChat: true, + }); + assert.isTrue(sideChat.targetThread.lineage.sideChat); + assert.equal(sideChat.targetThread.runtimeMode, "approval-required"); + + const sideChatSource = makeSourceProjection(sourceRun); + const sideChatProjection = { + ...sideChatSource, + thread: { + ...sideChatSource.thread, + lineage: { ...sideChatSource.thread.lineage, sideChat: true }, + }, + }; + const forkOfSideChat = yield* planFork(sourceRun, sideChatProjection); + assert.isUndefined(forkOfSideChat.targetThread.lineage.sideChat); + assert.equal(forkOfSideChat.targetThread.runtimeMode, "full-access"); + + const nested = yield* planFork(sourceRun, sideChatProjection, { sideChat: true }).pipe( + Effect.flip, + ); + assert.equal(nested.cause, "A side chat cannot start another side chat."); + + const subagentSource = makeSourceProjection(sourceRun); + const fromSubagent = yield* planFork( + sourceRun, + { + ...subagentSource, + thread: { + ...subagentSource.thread, + lineage: { ...subagentSource.thread.lineage, relationshipToParent: "subagent" }, + }, + }, + { sideChat: true }, + ).pipe(Effect.flip); + assert.equal(fromSubagent.cause, "A subagent cannot start a side chat."); + + const delegatedSource = makeSourceProjection(sourceRun); + const fromDelegated = yield* planFork( + sourceRun, + { + ...delegatedSource, + thread: { + ...delegatedSource.thread, + delegatedFrom: { + environmentId: EnvironmentId.make("linked-environment"), + threadId: ThreadId.make("linked-parent"), + title: "Linked parent", + }, + }, + }, + { sideChat: true }, + ).pipe(Effect.flip); + assert.equal(fromDelegated.cause, "A subagent cannot start a side chat."); + }), +); diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 71ff9764868a..4717daf75e30 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -51,13 +51,15 @@ export function forkableSourceRunStatusError( export interface ThreadForkServiceV2Shape { readonly plan: (input: { readonly sourceProjection: Pick; - readonly sourceRun: OrchestrationV2Run; + /** Null only for a side chat whose parent has no stable run yet. */ + readonly sourceRun: OrchestrationV2Run | null; readonly sourceProviderThread: OrchestrationV2ProviderThread | undefined; readonly sourceTokenUsage?: OrchestrationV2ProviderTurnTokenUsage | undefined; readonly canonicalSourcePoint: OrchestrationV2ContextSourcePoint; readonly transferId: ContextTransferId; readonly targetThreadId: ThreadId; readonly title?: string; + readonly sideChat?: boolean; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; readonly createdAt: DateTime.Utc; @@ -73,11 +75,28 @@ export const layer: Layer.Layer = Layer.succeed( ThreadForkServiceV2, ThreadForkServiceV2.of({ plan: Effect.fn("ThreadForkService.plan")(function* (input) { - if (!isForkableSourceRunStatus(input.sourceRun.status)) { + const { sourceRun } = input; + const sourceLineage = input.sourceProjection.thread.lineage; + // A task a linked environment delegated here is a subagent of a remote thread. + if ( + input.sideChat && + (sourceLineage.sideChat || + sourceLineage.relationshipToParent === "subagent" || + input.sourceProjection.thread.delegatedFrom !== undefined) + ) { return yield* new ThreadForkPlanError({ sourceThreadId: input.sourceProjection.thread.id, targetThreadId: input.targetThreadId, - cause: forkableSourceRunStatusError(input.sourceRun), + cause: sourceLineage.sideChat + ? "A side chat cannot start another side chat." + : "A subagent cannot start a side chat.", + }); + } + if (sourceRun !== null && !isForkableSourceRunStatus(sourceRun.status)) { + return yield* new ThreadForkPlanError({ + sourceThreadId: input.sourceProjection.thread.id, + targetThreadId: input.targetThreadId, + cause: forkableSourceRunStatusError(sourceRun), }); } const targetThread: OrchestrationV2AppThread = { @@ -92,17 +111,19 @@ export const layer: Layer.Layer = Layer.succeed( title: input.title ?? `${input.sourceProjection.thread.title} fork`, // The source's title job completes only on the source thread. titleRegeneration: null, + // Side chats share the parent's worktree, so they start supervised. + ...(input.sideChat ? { runtimeMode: "approval-required" as const } : {}), activeProviderThreadId: null, lineage: { parentThreadId: input.sourceProjection.thread.id, relationshipToParent: "fork", rootThreadId: input.sourceProjection.thread.lineage.rootThreadId, + ...(input.sideChat ? { sideChat: true } : {}), }, - forkedFrom: { - type: "run", - threadId: input.sourceProjection.thread.id, - runId: input.sourceRun.id, - }, + forkedFrom: + sourceRun === null + ? null + : { type: "run", threadId: input.sourceProjection.thread.id, runId: sourceRun.id }, createdAt: input.createdAt, updatedAt: input.createdAt, archivedAt: null, @@ -124,10 +145,11 @@ export const layer: Layer.Layer = Layer.succeed( targetThreadId: input.targetThreadId, sourcePoint: input.canonicalSourcePoint, basePoint: null, - sourceProviderInstanceId: input.sourceRun.providerInstanceId, + sourceProviderInstanceId: + sourceRun?.providerInstanceId ?? input.sourceProjection.thread.providerInstanceId, // Only native forks retain this context; portable handoffs rebuild it. sourceTokenUsage: - (input.sourceRun.status === "completed" || input.sourceRun.status === "waiting") && + (sourceRun?.status === "completed" || sourceRun?.status === "waiting") && input.sourceProviderThread?.nativeThreadRef?.strength === "strong" ? input.sourceTokenUsage : undefined, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 15417783cd79..276d673a39d4 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -11,6 +11,7 @@ import type { import { type ChatAttachment, CommandId, + isListedThreadLineage, MessageId, OrchestrationV2StopWorkflowError, type OrchestrationV2StopWorkflowInput, @@ -418,7 +419,7 @@ export interface ThreadManagementServiceShape { Orchestrator.OrchestratorV2Error >; readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; - /** Omit `projectId` to list every project's threads. */ + /** Omit `projectId` to list every project's threads. Side chats are never listed. */ readonly listProjectThreads: (input: { readonly projectId?: ProjectId | undefined; readonly includeSubagents: boolean; @@ -793,9 +794,10 @@ const make = Effect.gen(function* () { ), Effect.map((snapshot) => snapshot.threads - .filter( - (thread) => - input.includeSubagents || thread.lineage.relationshipToParent !== "subagent", + .filter((thread) => + input.includeSubagents + ? thread.lineage.sideChat !== true + : isListedThreadLineage(thread.lineage), ) .toSorted( (left, right) => diff --git a/apps/server/src/orchestration-v2/ThreadSearch.test.ts b/apps/server/src/orchestration-v2/ThreadSearch.test.ts index 5d816133c89c..b8d75340ec6b 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.test.ts @@ -66,6 +66,7 @@ const thread = ( readonly archivedAt?: DateTime.Utc; readonly deletedAt?: DateTime.Utc; readonly subagentOf?: ThreadId; + readonly sideChatOf?: ThreadId; readonly title?: string; readonly settledAt?: DateTime.Utc; readonly linkedPullRequest?: ThreadLinkedPullRequest; @@ -98,7 +99,14 @@ const thread = ( relationshipToParent: "subagent", rootThreadId: overrides.subagentOf, } - : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + : overrides.sideChatOf + ? { + parentThreadId: overrides.sideChatOf, + relationshipToParent: "fork", + rootThreadId: overrides.sideChatOf, + sideChat: true, + } + : { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, forkedFrom: null, createdAt: at(0), updatedAt: at(0), @@ -366,13 +374,14 @@ it.layer(layerTest)("ThreadSearch", (it) => { }), ); - it.effect("matches inside identifiers and skips subagent threads and short tokens", () => + it.effect("matches inside identifiers and skips hidden threads and short tokens", () => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const search = yield* ThreadSearch.ThreadSearch; const project = ProjectId.make("project:search-tokens"); const parent = ThreadId.make("thread:tokens-parent"); const subagent = ThreadId.make("thread:tokens-subagent"); + const sideChat = ThreadId.make("thread:tokens-side-chat"); yield* createProject(project); yield* Effect.forEach( [ @@ -380,6 +389,8 @@ it.layer(layerTest)("ThreadSearch", (it) => { message(parent, "parent", "user", "Rename ThreadSearchService before shipping"), thread(subagent, project, { subagentOf: parent }), message(subagent, "subagent", "assistant", "ThreadSearchService renamed"), + thread(sideChat, project, { sideChatOf: parent, title: "ThreadSearchService question" }), + message(sideChat, "side-chat", "user", "Why rename ThreadSearchService?"), ], projections.apply, { discard: true }, diff --git a/apps/server/src/orchestration-v2/ThreadSearch.ts b/apps/server/src/orchestration-v2/ThreadSearch.ts index 62c7f4eed45e..3c97122c1c63 100644 --- a/apps/server/src/orchestration-v2/ThreadSearch.ts +++ b/apps/server/src/orchestration-v2/ThreadSearch.ts @@ -154,7 +154,7 @@ export function buildSearchSnippet(text: string, matchStart: number): string { return `${start > 0 ? "…" : ""}${shown.slice(0, end)}${cut ? "…" : ""}`; } -// Hidden subagent threads must be filtered before the hit limit so visible matches aren't lost. +// Filter hidden subagents and side chats before the hit limit so visible matches aren't lost. // Word starts and ranking follow the title rules in JS. // Without FTS5 trigrams, or for two-character CJK words, scan the same eligible messages. // The scan folds ASCII case only, so accent variants and non-ASCII case variants may not match. @@ -174,6 +174,7 @@ export const messageHitsQuery = ( AND threads.thread_id NOT IN (SELECT value FROM json_each(${excludedThreadIds})) AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 ) ${ match === null @@ -332,6 +333,7 @@ export const make = Effect.gen(function* () { AND projects.deleted_at IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND (${projectId} IS NULL OR threads.project_id = ${projectId}) `, }); @@ -362,6 +364,7 @@ export const make = Effect.gen(function* () { AND projects.deleted_at IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' AND json_extract(threads.payload_json, '$.settledAt') < ${before} AND (${projectIds} IS NULL @@ -384,6 +387,7 @@ export const make = Effect.gen(function* () { AND json_extract(threads.payload_json, '$.archivedAt') IS NULL AND json_extract(threads.payload_json, '$.lineage.relationshipToParent') IS NOT 'subagent' + AND json_extract(threads.payload_json, '$.lineage.sideChat') IS NOT 1 AND json_extract(threads.payload_json, '$.settledOverride') = 'settled' AND json_extract(threads.payload_json, '$.settledAt') < ${before} AND (${projectIds} IS NULL diff --git a/apps/server/src/orchestration-v2/ThreadStream.test.ts b/apps/server/src/orchestration-v2/ThreadStream.test.ts index a5059eada443..5c9fd310e93a 100644 --- a/apps/server/src/orchestration-v2/ThreadStream.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStream.test.ts @@ -1,8 +1,13 @@ import { + ContextHandoffId, + ContextTransferId, EventId, OrchestrationV2ThreadStreamItem, + ProviderThreadId, + RunId, ThreadId, TurnItemId, + type OrchestrationV2ContextHandoff, type OrchestrationV2ProjectedTurnItem, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, @@ -149,11 +154,31 @@ describe("decideThreadResume", () => { }); it("builds socket fallback snapshots with a bounded timeline and history cursor", () => { + const projection = timelineProjection(80); + // A delivered side chat digest stays "ready", which bounded snapshots keep. + const sideChatHandoff = { + id: ContextHandoffId.make("side-chat-handoff"), + transferId: ContextTransferId.make("side-chat-transfer"), + threadId: THREAD_ID, + targetRunId: RunId.make("side-chat-run"), + fromProviderThreadIds: [], + toProviderThreadId: ProviderThreadId.make("side-chat-provider-thread"), + coveredRunOrdinals: { from: 1, to: 1 }, + strategy: "manual_context", + status: "ready", + summaryMessageId: null, + summaryText: "x".repeat(8_000), + delivery: { nativeThreadId: "native", status: "inline", itemIds: [] }, + createdByProviderInstanceId: null, + createdAt: NOW, + updatedAt: NOW, + } satisfies OrchestrationV2ContextHandoff; const snapshot = buildBoundedThreadStreamSnapshot({ snapshotSequence: 44, - projection: timelineProjection(80), + projection: { ...projection, contextHandoffs: [sideChatHandoff] }, }); + expect(snapshot.projection.contextHandoffs.map((handoff) => handoff.summaryText)).toEqual([""]); expect(snapshot.kind).toBe("snapshot"); expect(snapshot.snapshotSequence).toBe(44); expect(snapshot.projection.visibleTurnItems).toHaveLength(75); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a04fe4c7c137..037f2a394f28 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -595,6 +595,220 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i ); }), ); + + it.effect( + "deletes a side chat with its parent only after the side chat's in-flight command", + () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectId = ProjectId.make("runtime-side-chat-delete"); + const parentId = ThreadId.make("runtime-side-chat-delete-parent"); + const sideChatId = ThreadId.make("runtime-side-chat-delete-child"); + const updateCommandId = CommandId.make("runtime-side-chat-delete-update"); + yield* projects.create({ + commandId: CommandId.make("runtime-side-chat-delete-project"), + projectId, + title: "Side chat deletion", + workspaceRoot: "/work/side-chat-deletion", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-side-chat-delete-create"), + createdBy: "user", + creationSource: "web", + threadId: parentId, + projectId, + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-delete-fork"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + const updateReady = yield* Deferred.make(); + const releaseUpdate = yield* Deferred.make(); + const deletionQueued = yield* Deferred.make(); + const commitCommand = eventSink.commitCommand; + const withLock = executor.withLock; + let sideChatLockRequests = 0; + const commitSpy = vi + .spyOn(eventSink, "commitCommand") + .mockImplementation((input) => + input.commandId === updateCommandId + ? Deferred.succeed(updateReady, undefined).pipe( + Effect.andThen(Deferred.await(releaseUpdate)), + Effect.andThen(commitCommand(input)), + ) + : commitCommand(input), + ); + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => { + if (key !== sideChatId || ++sideChatLockRequests !== 2) return withLock(key, effect); + return Deferred.succeed(deletionQueued, undefined).pipe( + Effect.andThen(withLock(key, effect)), + ); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + yield* Effect.gen(function* () { + const updateFiber = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: updateCommandId, + threadId: sideChatId, + title: "Updated before deletion", + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.raceFirst( + Deferred.await(updateReady), + Fiber.join(updateFiber).pipe( + Effect.andThen( + Effect.die("The update completed before reaching its commit barrier."), + ), + ), + ); + const deleteFiber = yield* orchestrator + .dispatch({ + type: "thread.delete", + commandId: CommandId.make("runtime-side-chat-delete-parent"), + threadId: parentId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.raceFirst( + Deferred.await(deletionQueued), + Fiber.join(deleteFiber).pipe( + Effect.andThen(Effect.die("Parent deletion bypassed the side chat's command.")), + ), + ); + yield* Deferred.succeed(releaseUpdate, undefined); + yield* Fiber.join(updateFiber); + yield* Fiber.join(deleteFiber); + const sideChat = yield* orchestrator.getThreadProjection(sideChatId); + assert.isNotNull(sideChat.thread.deletedAt); + assert.equal(sideChat.thread.title, "Updated before deletion"); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + commitSpy.mockRestore(); + lockSpy.mockRestore(); + }), + ), + ); + }), + ); + + // A fork from a side chat onto its parent used to lock the side chat, then the parent: the + // reverse of the parent delete's order. + it.effect("a parent delete and a fork from its side chat onto the parent do not deadlock", () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const projectId = ProjectId.make("runtime-side-chat-lock-order"); + const parentId = ThreadId.make("runtime-side-chat-lock-order-parent"); + const sideChatId = ThreadId.make("runtime-side-chat-lock-order-child"); + yield* projects.create({ + commandId: CommandId.make("runtime-side-chat-lock-order-project"), + projectId, + title: "Side chat lock order", + workspaceRoot: "/work/side-chat-lock-order", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("runtime-side-chat-lock-order-create"), + createdBy: "user", + creationSource: "web", + threadId: parentId, + projectId, + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-lock-order-fork"), + sourceThreadId: parentId, + targetThreadId: sideChatId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }); + + const deleteHoldsParent = yield* Deferred.make(); + const releaseDelete = yield* Deferred.make(); + const forkWaitsForParent = yield* Deferred.make(); + const withLock = executor.withLock; + let parentLockRequests = 0; + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => { + if (key !== parentId) return withLock(key, effect); + // Dispatch builds locks it may not run, so count when one runs. + return Effect.suspend(() => + ++parentLockRequests === 1 + ? withLock( + key, + Deferred.succeed(deleteHoldsParent, undefined).pipe( + Effect.andThen(Deferred.await(releaseDelete)), + Effect.andThen(effect), + ), + ) + : Deferred.succeed(forkWaitsForParent, undefined).pipe( + Effect.andThen(withLock(key, effect)), + ), + ); + }; + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + yield* Effect.gen(function* () { + const deleteFiber = yield* orchestrator + .dispatch({ + type: "thread.delete", + commandId: CommandId.make("runtime-side-chat-lock-order-delete"), + threadId: parentId, + }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(deleteHoldsParent); + const forkFiber = yield* orchestrator + .dispatch({ + type: "thread.fork", + commandId: CommandId.make("runtime-side-chat-lock-order-fork-back"), + sourceThreadId: sideChatId, + targetThreadId: parentId, + sourcePoint: { type: "latest_stable" }, + sideChat: true, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(forkWaitsForParent); + yield* Deferred.succeed(releaseDelete, undefined); + yield* Fiber.join(deleteFiber); + yield* Fiber.join(forkFiber); + assert.isNotNull((yield* orchestrator.getThreadProjection(parentId)).thread.deletedAt); + assert.isNotNull((yield* orchestrator.getThreadProjection(sideChatId)).thread.deletedAt); + }).pipe(Effect.ensuring(Effect.sync(() => lockSpy.mockRestore()))); + }), + ); }); const layerSharedApplicationDataPlaneTest = Layer.mergeAll( diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts index 230758714b38..161cb05f8e9e 100644 --- a/apps/server/src/pullRequest/linkedThreads.test.ts +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -9,7 +9,7 @@ import { listLinkedPullRequestThreads } from "./linkedThreads.ts"; const encodePayload = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); it.effect( - "finds active and archived threads for exactly one pull request, excluding deleted and dismissed links", + "finds active and archived threads for exactly one pull request, excluding deleted, dismissed, and side chat links", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -55,6 +55,13 @@ it.effect( source: "created", }, { id: "deleted", host: "github.com", repository: "acme/web", number: 7, source: "manual" }, + { + id: "side-chat", + host: "github.com", + repository: "acme/web", + number: 7, + source: "manual", + }, { id: "dismissed", host: "github.com", @@ -87,6 +94,7 @@ it.effect( for (const fixture of fixtures) { const payload = yield* encodePayload({ linkedPullRequest: null, + ...(fixture.id === "side-chat" ? { lineage: { sideChat: true } } : {}), pullRequests: [{ ...fixture, linkedAt: createdAt, snapshot: null, stack: null }], }); yield* sql` diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index c7a2b01946a2..b140714531ad 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -39,6 +39,7 @@ export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThre WHERE json_extract(link.value, '$.number') = ${key.number} AND json_extract(link.value, '$.source') != 'stack-dismissed' AND t.deleted_at IS NULL + AND json_extract(t.payload_json, '$.lineage.sideChat') IS NOT 1 ORDER BY "updatedAt" DESC, id ASC `.pipe(NodeSqliteClient.readOnly); const threads = rows diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 54742a0af7d6..bad5b0d20d2a 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -1,10 +1,11 @@ -import type { - EnvironmentId, - OrchestrationV2DomainEvent, - OrchestrationV2ThreadShell, - OrchestrationV2TurnItem, - Project, - ThreadId, +import { + isListedThreadLineage, + type EnvironmentId, + type OrchestrationV2DomainEvent, + type OrchestrationV2ThreadShell, + type OrchestrationV2TurnItem, + type Project, + type ThreadId, } from "@t3tools/contracts"; import { RelayApi, @@ -514,10 +515,11 @@ export const make = Effect.gen(function* () { // Read only this thread; full shell reads would make each activity event scale with all threads. const threadShell = yield* threads.getThreadShell(threadId); if ( - threadShell?.lineage.relationshipToParent === "subagent" && + threadShell !== null && + !isListedThreadLineage(threadShell.lineage) && !(yield* Ref.get(publishedStateByThreadRef)).has(threadId) ) { - // Subagents have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. + // Subagents and side chats have no relay rows to clear; per-event tombstones would needlessly redeliver user aggregates. return; } const thread = @@ -727,9 +729,9 @@ export const make = Effect.gen(function* () { } const environmentId = yield* serverEnvironment.getEnvironmentId; const projectSnapshot = yield* projects.snapshot; - // Subagents never publish awareness, and they are most threads. + // Subagents and side chats never publish awareness, and subagents are most threads. const topLevelThreadIds = (yield* threads.listActiveShellIndex()).flatMap((thread) => - thread.lineage.relationshipToParent === "subagent" ? [] : [thread.id], + isListedThreadLineage(thread.lineage) ? [thread.id] : [], ); const shellSnapshot = yield* threads.getShellSnapshot({ location: "active", diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b812ad07fa6d..54fef56e311d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -3,6 +3,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_SERVER_SETTINGS, + isListedThreadLineage, type ModelSelection, type Project, ProjectId, @@ -280,8 +281,7 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { workspaceRoot: serverConfig.cwd, }); const existingThread = (yield* threads.listActiveShellIndex()).find( - (thread) => - thread.projectId === project.id && thread.lineage.relationshipToParent !== "subagent", + (thread) => thread.projectId === project.id && isListedThreadLineage(thread.lineage), ); if (existingThread === undefined) { const serverSettings = yield* ServerSettings.ServerSettingsService; diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c1293e330646..e18cdc4320c7 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -721,6 +721,7 @@ describe("subagent worktree ownership", () => { "pending", "unrelated", "fork", + "side-chat", "recent-activity", "recent-inactive", "recent-grace", @@ -774,7 +775,12 @@ describe("subagent worktree ownership", () => { rootThreadId: scenario === "wrong-root" ? ThreadId.make("other-root") : root.lineage.rootThreadId, relationshipToParent: - scenario === "fork" ? "fork" : scenario === "unrelated" ? null : "subagent", + scenario === "fork" || scenario === "side-chat" + ? "fork" + : scenario === "unrelated" + ? null + : "subagent", + ...(scenario === "side-chat" ? { sideChat: true } : {}), }, status: scenario === "running" ? "running" : "completed", pendingBackgroundTasks: scenario === "pending" ? [{ taskId: "task", kind: "command" }] : [], @@ -808,7 +814,9 @@ describe("subagent worktree ownership", () => { root, ]); const report = yield* fixture.service.runNow; - const removed = ["idle", "grandchild", "isolated-owner", "fork-owner"].includes(scenario); + const removed = ["idle", "grandchild", "isolated-owner", "fork-owner", "side-chat"].includes( + scenario, + ); const shared = [ "unrelated", "fork", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index b9f1e1f83952..580d520e3183 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -144,7 +144,7 @@ export function storageCleanupActivityAt(thread: OrchestrationV2ThreadShell): nu ); } -/** Only a complete subagent chain within this worktree shares ownership. */ +/** Only a complete subagent or side chat chain within this worktree shares ownership. */ function worktreeOwner>( threads: ReadonlyArray, ) { @@ -159,7 +159,8 @@ function worktreeOwner(input: { readonly profileId?: PreviewOpenInput["profileId"]; readonly background?: boolean; }): Promise> { + // Read before any await: a side chat pane that unmounts meanwhile drops its host mapping. + const threadRef = rightPanelHostRef(input.threadRef); const defaults = await resolveBrowserDefaults().catch( (cause: unknown) => new BrowserSettingsReadError({ cause }), ); if (defaults instanceof BrowserSettingsReadError) { return AsyncResult.failure(Cause.fail(defaults)); } - const runtime = previewRuntimeFor(input.threadRef.environmentId); - const previousActiveTabId = readThreadPreviewState(input.threadRef).activeTabId; + const runtime = previewRuntimeFor(threadRef.environmentId); + const previousActiveTabId = readThreadPreviewState(threadRef).activeTabId; // The server changes the active tab, not panel selection; selection changes come from the user. const selectedSurface = () => - selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, input.threadRef) - ?.id ?? null; + selectSelectedRightPanelSurface(useRightPanelStore.getState().byThreadKey, threadRef)?.id ?? + null; const surfaceBeforeOpen = selectedSurface(); const result = await input.openPreview({ - environmentId: input.threadRef.environmentId, + environmentId: threadRef.environmentId, input: { - threadId: input.threadRef.threadId, + threadId: threadRef.threadId, url: input.url, viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, @@ -81,21 +87,21 @@ export async function openUrlInPreview(input: { }, }); return mapAtomCommandResult(result, (snapshot) => { - rememberPreviewUrl(input.threadRef, input.url); + rememberPreviewUrl(threadRef, input.url); if (input.background) { - updatePreviewServerSnapshot(input.threadRef, snapshot); + updatePreviewServerSnapshot(threadRef, snapshot); // Restore focus unless the user picked a tab while opening this one. if ( previousActiveTabId && - readThreadPreviewState(input.threadRef).activeTabId === snapshot.tabId && + readThreadPreviewState(threadRef).activeTabId === snapshot.tabId && selectedSurface() === surfaceBeforeOpen ) { - setActivePreviewTab(input.threadRef, previousActiveTabId); + setActivePreviewTab(threadRef, previousActiveTabId); } return; } - applyPreviewServerSnapshot(input.threadRef, snapshot); - useRightPanelStore.getState().openBrowser(input.threadRef, snapshot.tabId); + applyPreviewServerSnapshot(threadRef, snapshot); + useRightPanelStore.getState().openBrowser(threadRef, snapshot.tabId); }); } @@ -124,6 +130,7 @@ export async function openFileInPreview(input: { ), ); } + const hostRef = rightPanelHostRef(input.threadRef); const insideWorkspace = mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined; const assetResult = await input.createAssetUrl({ @@ -146,7 +153,7 @@ export async function openFileInPreview(input: { ); } return openUrlInPreview({ - threadRef: input.threadRef, + threadRef: hostRef, url: assetUrl, openPreview: input.openPreview, }); diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts index 0223dfb9d5bd..3e2cacc57e73 100644 --- a/apps/web/src/closedViewStore.test.ts +++ b/apps/web/src/closedViewStore.test.ts @@ -54,6 +54,12 @@ describe("closedViewStore", () => { threadRef: refA, surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 }, }, + { + id: "bad", + kind: "panel-tab", + threadRef: refA, + surface: { kind: "side-chat", id: "side-chat:x" }, + }, ])("discards incomplete saved views %j while retaining valid history", async (entry) => { const id = useClosedViewStore.getState().remember(diff(refA)); const { storage, name } = useClosedViewStore.persist.getOptions(); @@ -85,6 +91,11 @@ describe("closedViewStore", () => { it("removes private metadata and malformed entries from history saved by earlier versions", async () => { const store = useClosedViewStore.getState(); + const sideChatId = store.remember({ + kind: "panel-tab", + threadRef: refA, + surface: { id: "side-chat:side", kind: "side-chat", threadId: ThreadId.make("side") }, + }); const publicId = store.remember(diff(refA)); const browserId = store.remember({ kind: "browser", @@ -115,6 +126,7 @@ describe("closedViewStore", () => { expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([ browserId, publicId, + sideChatId, ]); const saved = JSON.stringify(await storage!.getItem(name!)); expect(saved).not.toContain("private.example"); diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts index 76fee83a83d0..b5db7bd741be 100644 --- a/apps/web/src/closedViewStore.ts +++ b/apps/web/src/closedViewStore.ts @@ -83,6 +83,8 @@ const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => { typeof surface.target.name === "string" && (surface.target.platform === "ios" || surface.target.platform === "android"))) ); + case "side-chat": + return typeof surface.threadId === "string" && surface.id === `side-chat:${surface.threadId}`; case "pull-request": return ( typeof surface.projectId === "string" && diff --git a/apps/web/src/components/BackgroundThreadAnnouncer.tsx b/apps/web/src/components/BackgroundThreadAnnouncer.tsx index 82c280e6e56f..3176c8132eed 100644 --- a/apps/web/src/components/BackgroundThreadAnnouncer.tsx +++ b/apps/web/src/components/BackgroundThreadAnnouncer.tsx @@ -1,5 +1,6 @@ import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { isListedThreadLineage } from "@t3tools/contracts"; import type { AnyRouter } from "@tanstack/react-router"; import type { AtomRegistry } from "effect/reactivity"; @@ -42,9 +43,8 @@ export function startBackgroundThreadAnnouncements( const now = new Date().toISOString(); const snapshots: BackgroundThreadSnapshot[] = []; for (const thread of threads) { - // Subagents have no row of their own; their requests surface on the parent. - if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") - continue; + // Subagents and side chats have no row of their own; their requests surface on the parent. + if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) continue; if (!liveEnvironmentIds.has(thread.environmentId)) continue; snapshots.push({ key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63ff8b8463ee..22ccaaaf807e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -61,6 +61,7 @@ import { type AssistantCitation, type ChatFileAttachment, isProviderNativeSubagentThread, + orchestrationV2WorktreePending, type EnvironmentId, type MessageId, type ModelSelection, @@ -187,6 +188,7 @@ import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; +import { isInSideChatPane, type SideChatParentPanel } from "~/lib/sideChatPane"; import { NO_PROVIDER_MODEL_SELECTION, providerInstanceEntriesFor, @@ -273,6 +275,7 @@ import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; +import { useSideChat } from "./chat/useSideChat"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; @@ -390,6 +393,8 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + /** A side chat in its parent's right panel; the parent keeps the keyboard and workspace UI. */ + embedded?: SideChatParentPanel; routeKind: "server"; draftId?: never; } @@ -399,6 +404,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + embedded?: never; routeKind: "draft"; draftId: DraftId; }; @@ -413,7 +419,9 @@ export default function ChatView(props: ChatViewProps) { onDiffPanelOpen, reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, + embedded: parentPanel, } = props; + const embedded = parentPanel !== undefined; const canOperateThread = useEnvironmentScope(environmentId, AuthOrchestrationOperateScope); const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const draftId = routeKind === "draft" ? props.draftId : null; @@ -866,7 +874,29 @@ export default function ChatView(props: ChatViewProps) { : scopeThreadRef(delegatedFrom.environmentId, delegatedFrom.threadId); // The title it had at delegation, until this client sees the parent itself. const delegatedFromThread = useQuietThreadShell(delegatedFromRef); + const sideChatParentRef = + activeThread?.lineage.sideChat === true && activeThread.lineage.parentThreadId !== null + ? scopeThreadRef(activeThread.environmentId, activeThread.lineage.parentThreadId) + : null; + const sideChatParent = useQuietThreadShell(sideChatParentRef); const parentThreadLink = (() => { + if (sideChatParentRef !== null) { + return { + title: sideChatParent?.title ?? "Parent thread", + sideChat: true, + // Embedded, the parent is the view around it. + ...(embedded + ? {} + : { + open: (): void => { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(sideChatParentRef), + }); + }, + }), + }; + } if (parentSubagentThreadRef !== null) { return { title: parentSubagentThread?.title ?? "Parent thread", @@ -1066,7 +1096,9 @@ export default function ChatView(props: ChatViewProps) { renderedRightPanelSurface, ); const canMaximizeRightPanel = rightPanelOpen; - const rightPanelMaximized = canMaximizeRightPanel && rightPanelState.maximized === true; + // An embedded side chat has no panel; a stale maximized flag would hide its own column. + const rightPanelMaximized = + !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); const [chatCanvasStore] = useState(createChatCanvasStore); @@ -1720,13 +1752,15 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - const showComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - }); + const showComposerContextStrip = + !embedded && + shouldShowComposerContextStrip({ + isDraftHeroState, + persistInActiveThreads: settings.persistComposerContextStrip, + hasActiveProject: activeProject !== null && !showProviderSubagentBar, + isGitRepo, + showEnvironmentIndicator: showComposerEnvironmentIndicator, + }); const terminalShortcutLabelOptions = { context: { terminalFocus: true, @@ -1807,7 +1841,10 @@ export default function ChatView(props: ChatViewProps) { }; const onInterruptStable = useStableCallback(onInterrupt); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window listener for the desktop snapshot event that focuses the composer - useEffect(() => subscribeSnapShotComposerFocus(focusComposerStable), [focusComposerStable]); + useEffect( + () => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)), + [embedded, focusComposerStable], + ); const scheduleComposerFocus = () => { window.requestAnimationFrame(() => { focusComposer(); @@ -1968,8 +2005,8 @@ export default function ChatView(props: ChatViewProps) { : linkedThreadPullRequestKey; const activeRunningTurnId = activeRuntime?.activeRunId ?? null; useProactivePanels({ - threadRef: isServerThread ? activeThreadRef : null, - enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet, + threadRef: isServerThread && !embedded ? activeThreadRef : null, + enabled: settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet && !embedded, settingsHydrated: clientSettingsHydrated, threadDetailLoading, runningTurnId: activeRunningTurnId, @@ -2100,18 +2137,18 @@ export default function ChatView(props: ChatViewProps) { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen) return; const frame = window.requestAnimationFrame(() => { focusComposerStable(); }); return () => { window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, terminalUiState.terminalOpen]); + }, [activeThread?.id, embedded, focusComposerStable, terminalUiState.terminalOpen]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus useEffect(() => { - if (!activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; + if (embedded || !activeThread?.id || terminalUiState.terminalOpen || isMobileViewport) return; let frame: number | null = null; const onWindowFocus = () => { if (frame !== null) window.cancelAnimationFrame(frame); @@ -2120,7 +2157,12 @@ export default function ChatView(props: ChatViewProps) { frame = window.requestAnimationFrame(() => { frame = null; if (document.querySelector("[data-mcp-app-fullscreen]") !== null) return; - if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposerStable(); + if ( + !isInSideChatPane(document.activeElement) && + shouldRefocusComposerOnWindowFocus(document.activeElement) + ) { + focusComposerStable(); + } }); }); }; @@ -2129,7 +2171,13 @@ export default function ChatView(props: ChatViewProps) { window.removeEventListener("focus", onWindowFocus); if (frame !== null) window.cancelAnimationFrame(frame); }; - }, [activeThread?.id, focusComposerStable, isMobileViewport, terminalUiState.terminalOpen]); + }, [ + activeThread?.id, + embedded, + focusComposerStable, + isMobileViewport, + terminalUiState.terminalOpen, + ]); // Promotion drops draftId while a send may still be dispatching, so key by thread. const [localDispatchThreadId, setLocalDispatchThreadId] = useState(threadId); @@ -2334,6 +2382,7 @@ export default function ChatView(props: ChatViewProps) { pullRequestPanelTarget, threadPanelPresentation, isServerThread, + embedded, isGitRepo, browserAvailable, canOperatePreview, @@ -2619,8 +2668,8 @@ export default function ChatView(props: ChatViewProps) { // composer effect above. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the open drawer terminal when entering a thread useEffect(() => { - if (activeThreadRef) return focusTerminalOnThreadEnter(activeThreadRef); - }, [activeThreadRef]); + if (activeThreadRef && !embedded) return focusTerminalOnThreadEnter(activeThreadRef); + }, [activeThreadRef, embedded]); const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, @@ -2629,7 +2678,9 @@ export default function ChatView(props: ChatViewProps) { previewOpen: previewPanelOpen, editableFocus: isEditableFocused(eventTarget), modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - composerFocus: document.activeElement?.getAttribute("data-testid") === "composer-editor", + composerFocus: + document.activeElement?.getAttribute("data-testid") === "composer-editor" && + isInSideChatPane(document.activeElement) === embedded, draftThreadRoute: routeKind === "draft", turnRunning: phase === "running", composerRequiresModifier: composerRequiresModifier(settings.sendShortcut, promptRef.current), @@ -2650,6 +2701,8 @@ export default function ChatView(props: ChatViewProps) { useChatShortcuts({ enabled: activeThreadId !== null && !backgroundThread, + embedded, + composerElement: composerOverlayElement, keybindings, scriptKeybindings, getShortcutContext, @@ -2704,13 +2757,56 @@ export default function ChatView(props: ChatViewProps) { // A focused desktop browser page forwards these chords as menu actions. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to desktop bridge menu actions that toggle the right panel useEffect(() => { + if (embedded) return; return window.desktopBridge?.onMenuAction((action) => { if (action === "rightPanel.toggle") toggleRightPanelStable(); else if (action === "rightPanel.toggleMaximized") toggleRightPanelMaximizedStable(); }); - }, [toggleRightPanelStable, toggleRightPanelMaximizedStable]); + }, [embedded, toggleRightPanelStable, toggleRightPanelMaximizedStable]); - usePasteToFocusComposer(activeThreadId, composerRef); + usePasteToFocusComposer(embedded ? null : activeThreadId, composerRef); + // A delegated task is a remote thread's subagent. + const isSubagentThread = + activeThread?.lineage.relationshipToParent === "subagent" || delegatedFrom !== null; + // Side chats do not nest or start from subagents, and older servers would fork a listed thread. + const sideChatsOffered = + !embedded && + canOperateThread && + activeThread?.lineage.sideChat !== true && + !isSubagentThread && + serverConfig?.environment.capabilities.sideChats === true; + const sideChat = useSideChat({ + threadRef: sideChatsOffered && isServerThread ? activeThreadRef : null, + onError: (message) => { + if (activeThread) setThreadError(activeThread.id, message); + }, + }); + // A maximized panel covers the terminal drawer the command runs in. + const runSideChatShellCommandStable = useStableCallback((command: string) => { + if (activeThreadRef) useRightPanelStore.getState().setMaximized(activeThreadRef, false); + runShellCommandStable(command); + }); + // Main-thread surfaces show this reason; `/side` sent anyway gets it as a toast. + const sideChatDisabledReason = + activeThread?.lineage.sideChat === true + ? "Side chats cannot start side chats." + : isSubagentThread + ? "Subagent threads cannot start side chats." + : serverConfig === null + ? "Side chats are not ready yet. Try again in a moment." + : serverConfig.environment.capabilities.sideChats !== true + ? "Update this environment's T3 Code server to use side chats." + : sideChatsOffered && !isServerThread + ? "Side chats start after the first message." + : sideChat.available && + orchestrationV2WorktreePending( + activeThread?.worktreePath ?? null, + serverProjection?.runs ?? [], + ) + ? "Available once the thread's worktree is set up." + : null; + const openSideChatStable = useStableCallback(() => void sideChat.open()); + const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ environmentId, @@ -2873,6 +2969,8 @@ export default function ChatView(props: ChatViewProps) { anchorNewTurn, acknowledgeActiveThreadWoke, handleInteractionModeChange, + onSideChat: sideChat.available ? sendSideChatStable : undefined, + sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, }); @@ -3011,10 +3109,7 @@ export default function ChatView(props: ChatViewProps) { !serverProjection?.messages.some((committed) => committed.id === message.id), ); const scrollToEndSummary = - [ - childInputNow === null ? null : "Subagent waiting", - queuedRunCount === 0 ? null : `${queuedRunCount} queued`, - ] + [childInputNow?.waitingLabel ?? null, queuedRunCount === 0 ? null : `${queuedRunCount} queued`] .filter((part) => part !== null) .join(" · ") || null; // The end of the timeline holds the thread's present and next turns: what it is doing now, @@ -3099,7 +3194,7 @@ export default function ChatView(props: ChatViewProps) { ); if (!activeThread) { - return ; + return embedded ? null : ; } const rightPanelContent = activeThreadRef ? ( @@ -3116,6 +3211,10 @@ export default function ChatView(props: ChatViewProps) { getShortcutContext={getShortcutContextStable} onSendAnnotation={sendPreviewAnnotationStable} onCloseSurface={closeRightPanelSurface} + sideChatParentPanel={{ + openToolDiff: onOpenToolDiffStable, + runShellCommand: runSideChatShellCommandStable, + }} terminal={{ launchContext: activeTerminalLaunchContext ?? null, keybindings, @@ -3196,12 +3295,14 @@ export default function ChatView(props: ChatViewProps) { ref={setWorkspaceLayoutElement} className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" > - - {rightPanelControlsAtRoot ? panelLayoutControls : null} + {embedded ? null : ( + + )} + {rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null}
-
- {isElectron && rightPanelControlsAtRoot ? ( - + {isElectron && rightPanelControlsAtRoot ? ( + + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} + {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} + - ) : null} - {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl : null} - -
+ + )}
- {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser ? ( + {canOperatePreview && activeThreadRef && activeEnvironmentServerBrowser && !embedded ? ( ) : null} - {activeThreadRef && shownPreviewMiniPlayer ? ( + {activeThreadRef && shownPreviewMiniPlayer && !embedded ? ( ) : null} - + {embedded ? null : ( + <> + - + keybindings={keybindings} + availableEditors={availableEditors} + showOpenInPicker={showOpenInPicker} + openInShortcutEnabled={ + !( + rightPanelOpen && + renderedRightPanelSurface?.kind === "file" && + renderedRightPanelSurface.attachment === undefined + ) + } + gitCwd={gitCwd} + isGitRepo={isGitRepo} + envLocked={envLocked} + availableEnvironments={logicalProjectEnvironments} + autoEnvironmentLabel={autoEnvironmentLabel} + onAutoEnvironment={ + draftId && + !envLocked && + canAutoBalanceEnvironments && + loadBalancingSettings.loadBalancingEnabled + ? onAutoEnvironmentStable + : undefined + } + {...(canChangeEnvironment + ? { onEnvironmentChange: onEnvironmentChangeStable } + : {})} + onEnvModeChange={onEnvModeChangeStable} + envMode={envMode} + {...(canOverrideServerThreadEnvMode + ? { + activeThreadBranchOverride: activeThreadBranch, + onActiveThreadBranchOverrideChange: setPendingServerThreadBranch, + } + : {})} + startFromOrigin={startFromOrigin} + onStartFromOriginChange={onStartFromOriginChange} + {...(canCheckoutPullRequestIntoThread + ? { onCheckoutPullRequestRequest: openPullRequestDialogStable } + : {})} + onComposerFocusRequest={scheduleComposerFocusStable} + {...(isServerThread && isGitRepo + ? { onOpenChanges: openChangesFromThreadPanelStable } + : {})} + onRunProjectScript={runProjectScriptStable} + onAddProjectScript={saveProjectScriptStable} + onUpdateProjectScript={updateProjectScriptStable} + onDeleteProjectScript={deleteProjectScriptStable} + usage={{ + providerInstanceId: activeProviderInstanceId, + providers: providerStatuses, + sources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, + contextWindow: activeContextWindow, + }} + /> - + + + )}
- + {embedded ? null : ( + + )}
- {rightPanelPresent && activeThreadRef ? ( + {rightPanelPresent && activeThreadRef && !embedded ? ( {rightPanelContent} ) : null} - + {embedded ? null : }
diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 96d439252ac5..f9b47f75180b 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -32,7 +32,7 @@ import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/fil import { type ReactNode } from "react"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; -import { isSidebarSubagentThread } from "./Sidebar.logic"; +import { isSidebarHiddenThread } from "./Sidebar.logic"; export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; @@ -421,7 +421,7 @@ export function buildThreadActionItems thread.archivedAt === null && !isSidebarSubagentThread(thread), + (thread) => thread.archivedAt === null && !isSidebarHiddenThread(thread), ); return visibleThreads.map((thread) => { diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index f1b5520d4216..ce187f5db5e9 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -91,6 +91,7 @@ export function ReopenClosedViewShortcut() { environmentKnown: true, catalogReady: true, ownerExists: true, + sideChatExists: true, shellLive: true, panel, }; @@ -99,6 +100,10 @@ export function ReopenClosedViewShortcut() { environmentKnown: catalog.entries.has(ref.environmentId), catalogReady: catalog.isReady, ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + sideChatExists: + entry.kind !== "panel-tab" || + entry.surface.kind !== "side-chat" || + readThreadShell(scopeThreadRef(ref.environmentId, entry.surface.threadId)) !== null, shellLive: appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live", panel, diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index d74e03ea842a..596bdee96ad1 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; -import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + threadRuntimeIsActive, + type EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + useProjects, + useQuietThreadShell, + useServerConfigs, + useThreadShells, +} from "~/state/entities"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, @@ -12,6 +21,7 @@ import type { ProjectId, PullRequestState, ResolvedKeybindingsConfig, + ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { @@ -33,6 +43,7 @@ import { FileDiff, Files, Globe2, + MessagesSquare, Plus, TerminalSquare, } from "lucide-react"; @@ -130,6 +141,8 @@ export interface RightPanelTabsProps { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -137,6 +150,9 @@ export interface RightPanelTabsProps { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable?: boolean; + /** Overrides the default reason while side chats are available but cannot start. */ + sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } @@ -163,6 +179,7 @@ const SURFACE_DISABLED_REASONS = { pullRequest: "This thread's branch has no pull request yet.", pullRequests: "No linked pull requests are available for this thread.", device: "Devices are only available from a thread.", + sideChat: "Side chats start after the first message.", } as const; // Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. @@ -187,6 +204,7 @@ const SURFACE_UNAVAILABLE_HINTS = { pullRequest: "No pull request on this branch yet.", pullRequests: "No linked pull requests available.", device: "Available from a thread.", + sideChat: "Available after the first message.", } as const; type TabContextMenuAction = @@ -304,6 +322,8 @@ function RightPanelEmptyState(props: { onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; + /** Absent where side chats are not offered. */ + onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; @@ -311,6 +331,8 @@ function RightPanelEmptyState(props: { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + sideChatAvailable: boolean; + sideChatDisabledReason: string | null; }) { const [highlight, setHighlight] = useState(-1); @@ -372,6 +394,19 @@ function RightPanelEmptyState(props: { disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + description: "Ask a side question while this thread keeps working.", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; type SurfaceAction = (typeof actions)[number]; @@ -557,6 +592,8 @@ function surfaceTitle( return "Pull requests"; case "device": return surface.title ?? surface.target?.name ?? "Device"; + case "side-chat": + return "Side chat"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -645,6 +682,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "side-chat": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -656,6 +695,41 @@ function SurfaceIcon({ } } +/** What a side chat tab's dot shows and its title's accessible name says. */ +function sideChatTabState(shell: EnvironmentThreadShell | null) { + if (shell === null) return null; + if (shell.hasPendingApprovals || shell.pendingSubagentRequest === "approval") { + return "needs approval"; + } + if (shell.hasPendingUserInput || shell.pendingSubagentRequest === "user_input") { + return "needs input"; + } + return threadRuntimeIsActive(shell.runtime) ? "running" : null; +} + +// Static dots: a side chat tab can stay open for a whole run. +function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { + const state = sideChatTabState( + useQuietThreadShell( + props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), + ), + ); + return ( + <> + + {state === null ? null : ( + + )} + + ); +} + export function resolvePullRequestTabLink( threads: readonly Pick[], environmentId: EnvironmentId | null, @@ -813,7 +887,6 @@ function RightPanelTab( ) { const { surface, - title, audio, audioRuntimeTabId, onActivate, @@ -827,6 +900,13 @@ function RightPanelTab( id: surface.id, disabled: props.dragDisabled, }); + const sideChatShell = useQuietThreadShell( + surface.kind === "side-chat" && props.environmentId !== null + ? scopeThreadRef(props.environmentId, surface.threadId) + : null, + ); + const title = sideChatShell?.title ?? props.title; + const sideChatState = sideChatTabState(sideChatShell); const content = ( <> onClose(surface)}> @@ -896,6 +976,7 @@ function RightPanelTab( if (surface.kind === "device" && props.canRename) onStartRename(surface.id); }} className="cursor-pointer flex min-w-0 items-center" + aria-label={sideChatState === null ? undefined : `${title}, ${sideChatState}`} onClick={() => onActivate(surface)} > {title} @@ -1066,6 +1147,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.device, onClick: props.onAddDevice, }, + ...(props.onAddSideChat + ? [ + { + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + available: props.sideChatAvailable === true && !props.sideChatDisabledReason, + disabledReason: props.sideChatDisabledReason ?? SURFACE_DISABLED_REASONS.sideChat, + onClick: props.onAddSideChat, + }, + ] + : []), ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { @@ -1440,6 +1533,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddPullRequest={props.onAddPullRequest} onAddPullRequests={props.onAddPullRequests} onAddDevice={props.onAddDevice} + onAddSideChat={props.onAddSideChat} browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} @@ -1447,6 +1541,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { pullRequestAvailable={props.pullRequestAvailable} pullRequestsAvailable={props.pullRequestsAvailable} deviceAvailable={props.deviceAvailable} + sideChatAvailable={props.sideChatAvailable === true} + sideChatDisabledReason={props.sideChatDisabledReason ?? null} /> ) : ( props.children diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 43c87f530e45..6648ab93d515 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -16,7 +16,12 @@ import { type ThreadSearchShell, } from "@t3tools/client-runtime/state/thread-search"; import { foldSearchQuery, rankSearchItems, scoreThreadSearch } from "@t3tools/shared/paletteSearch"; -import type { ContextMenuItem, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + isListedThreadLineage, + type ContextMenuItem, + type EnvironmentId, + type ThreadId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/reactivity"; import { @@ -590,12 +595,12 @@ export async function archiveSelectedThreadEntries< return { archivedThreadKeys, mutationFailure: null, followupFailures }; } -export function isSidebarSubagentThread(thread: Pick): boolean { - return thread.lineage.relationshipToParent === "subagent"; +export function isSidebarHiddenThread(thread: Pick): boolean { + return !isListedThreadLineage(thread.lineage); } -// Subagent threads have no sidebar row, so an open subagent is represented by -// its nearest non-subagent ancestor. A broken chain (missing parent, cycle, +// Subagent and side-chat threads have no sidebar row, so an open one is represented by +// its nearest listed ancestor. A broken chain (missing parent, cycle, // other environment) resolves to the thread itself, which then matches no row. export function resolveSidebarRowThreadId( threadId: ThreadId, @@ -606,7 +611,7 @@ export function resolveSidebarRowThreadId( threads.find((thread) => thread.id === id && thread.environmentId === environmentId); const visited = new Set(); let thread = findThread(threadId); - while (thread !== undefined && isSidebarSubagentThread(thread)) { + while (thread !== undefined && isSidebarHiddenThread(thread)) { if (thread.lineage.parentThreadId === null || visited.has(thread.id)) return threadId; visited.add(thread.id); thread = findThread(thread.lineage.parentThreadId); diff --git a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx index 9280b9cbc065..6eed862ed7a0 100644 --- a/apps/web/src/components/chat/AssistantSelectionToolbar.tsx +++ b/apps/web/src/components/chat/AssistantSelectionToolbar.tsx @@ -5,7 +5,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { QuoteIcon } from "lucide-react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { use, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { captureAssistantTextSelection, @@ -18,6 +18,7 @@ import { type SelectionActionPoint, } from "~/lib/selectionActions"; import { Button } from "../ui/button"; +import { BackgroundThreadContext } from "./backgroundThread"; export function AssistantSelectionToolbar({ viewport, @@ -35,6 +36,7 @@ export function AssistantSelectionToolbar({ } | null>(null); const toolbarRef = useRef(null); const actionsRef = useRef | null>(null); + const backgroundThread = use(BackgroundThreadContext); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the toolbar DOM node and clamps its position inside the window before paint useLayoutEffect(() => { @@ -163,7 +165,8 @@ export function AssistantSelectionToolbar({ event.stopPropagation(); toolbar.focus({ preventScroll: true }); }; - document.addEventListener("keydown", focusActions, true); + // Tab belongs to the foreground chat; a background timeline only follows its selection. + if (!backgroundThread) document.addEventListener("keydown", focusActions, true); document.addEventListener("selectionchange", actions.selectionChanged); return () => { document.removeEventListener("keydown", focusActions, true); @@ -171,7 +174,7 @@ export function AssistantSelectionToolbar({ actions.dispose(); actionsRef.current = null; }; - }, [threadRef, viewport]); + }, [backgroundThread, threadRef, viewport]); if (!selection) return null; const tooLong = selection.citation.text.length > ASSISTANT_CITATION_MAX_TEXT_LENGTH; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c4963ad73ba3..6e2455bb340b 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,6 +1,7 @@ import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; +import { BackgroundThreadContext } from "./backgroundThread"; import { resolveCompatibleRuntimeMode, runtimeModeConfig, @@ -52,6 +53,7 @@ import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model" import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { memo, + use, useEffect, useEffectEvent, useId, @@ -381,6 +383,8 @@ export interface ChatComposerProps { keepFullHistory: boolean; onToggleKeepFullHistory: () => void; onUsageLimitsCommand?: (() => void) | undefined; + sideChatAvailable?: boolean; + sideChatDisabledReason?: string | null; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -1045,14 +1049,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRevertingCheckpointRef.current = isRevertingCheckpoint; }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown, blur and the desktop paste-as-text event to arm plain-text paste + const backgroundThread = use(BackgroundThreadContext); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to keydown, window blur and the desktop paste-as-text event to arm plain-text paste useEffect(() => { + // A background composer listens on its own form, so it never hears the main composer's keys. + const target: EventTarget | null = backgroundThread ? composerFormRef.current : window; + if (target === null) return; const armPasteAsTextShortcut = () => { // Electron's menu action can precede paste; browsers deliver keydown first. pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; }; - const onKeyDown = (event: KeyboardEvent) => { + const onKeyDown = (event: Event) => { if ( + event instanceof KeyboardEvent && event.target instanceof Node && composerFormRef.current?.contains(event.target) && isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) @@ -1070,22 +1079,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeElement.closest( 'input, textarea, select, button, a[href], summary, [contenteditable="true"], [contenteditable="plaintext-only"], [role="textbox"], [role="button"], [role="menuitem"], [role="option"]', ) !== null; + // A background composer takes no paste-to-focus, so it arms only for its own focus. if ( (activeElement instanceof Node && composerFormRef.current?.contains(activeElement)) || - !blocksPasteToFocus + (!blocksPasteToFocus && !backgroundThread) ) { armPasteAsTextShortcut(); } }; - window.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); - window.addEventListener("keydown", onKeyDown, true); - window.addEventListener("blur", onBlur); + target.addEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + target.addEventListener("keydown", onKeyDown, true); + // An arm lasts a second, so a background composer can skip the window blur reset. + if (!backgroundThread) window.addEventListener("blur", onBlur); return () => { - window.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); - window.removeEventListener("keydown", onKeyDown, true); + target.removeEventListener(DESKTOP_PASTE_AS_TEXT_EVENT, onDesktopPasteAsText); + target.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("blur", onBlur); }; - }, []); + }, [backgroundThread]); const composerSendState = deriveComposerSendState({ prompt, @@ -1120,6 +1131,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestRepository, threadPullRequests, planModeUiEnabled, + sideChatAvailable: props.sideChatAvailable === true, + sideChatDisabledReason: props.sideChatDisabledReason ?? null, compactSlashCommandAvailable, selectedProvider, selectedProviderSkills, @@ -1848,6 +1861,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } + if (item.command === "side" || item.command === "btw") { + if (props.sideChatDisabledReason) return; + const applied = applyPromptReplacement( + trigger.rangeStart, + trigger.rangeEnd, + `/${item.command} `, + { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd) }, + ); + if (applied) setComposerHighlightedItemId(null); + return; + } if (!planModeUiEnabled) return; void handleInteractionModeChange(item.command === "plan" ? "plan" : "default"); const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 60ee931fe5f0..37a002e2c270 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -9,6 +9,7 @@ import { CheckboxMark } from "../ui/checkbox"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Kbd } from "../ui/kbd"; import { cn } from "~/lib/utils"; +import { isInSideChatPane } from "~/lib/sideChatPane"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; @@ -151,6 +152,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; + if (isInSideChatPane(target)) return; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { return; } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8c779ee950d6..4c2816376605 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -53,7 +53,13 @@ import { import { type PendingApproval, type TimelineEntry } from "../../session-logic"; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; -import { ArrowRightIcon, BotIcon, CircleAlertIcon, Minimize2Icon } from "lucide-react"; +import { + ArrowRightIcon, + BotIcon, + CircleAlertIcon, + MessagesSquareIcon, + Minimize2Icon, +} from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { Skeleton } from "../ui/skeleton"; @@ -251,9 +257,14 @@ interface MessagesTimelineProps { onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; turnItemSource?: TimelineRowSharedState["turnItemSource"]; - /** The thread this one is a subagent of; `open` is absent when it cannot be opened from here. */ + /** + * The thread this one is a subagent or side chat of; `open` is absent when it cannot be + * opened from here. + */ parentThreadLink?: { readonly title: string; + /** A side chat's inherited rows collapse into this link. */ + readonly sideChat?: boolean; /** Where it runs, when that is another environment. */ readonly environmentLabel?: string; readonly open?: () => void; @@ -1098,13 +1109,13 @@ const ConversationTimeline = memo(function ConversationTimeline({
; + case "side-chat": + // Not lazy: revealing a suspended tiptap editor tears down its view and crashes the composer. + return ( + props.onCloseSurface(surface)} + /> + ); case "device": return ( diff --git a/apps/web/src/components/chat/SideChatPane.tsx b/apps/web/src/components/chat/SideChatPane.tsx new file mode 100644 index 000000000000..1a5bd1141b31 --- /dev/null +++ b/apps/web/src/components/chat/SideChatPane.tsx @@ -0,0 +1,179 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; +import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-workflows"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + AuthOrchestrationOperateScope, + type ScopedThreadRef, + type ThreadId, +} from "@t3tools/contracts"; +import { Trash2Icon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { ComposerHandleContext } from "../../composerHandleContext"; +import { useClientSettings } from "../../hooks/useSettings"; +import type { SideChatParentPanel } from "../../lib/sideChatPane"; +import { readLocalApi } from "../../localApi"; +import { hostRightPanel, useRightPanelStore } from "../../rightPanelStore"; +import { useQuietThreadProjection, useQuietThreadShell } from "../../state/entities"; +import { useEnvironmentScope } from "../../state/session"; +import { useEnvironmentShellHasSnapshot } from "../../state/shell"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import ChatView from "../ChatView"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { BackgroundThreadContext } from "./backgroundThread"; + +/** A side chat tab: its title, Bring back, Delete and Close, over the side chat's own chat. */ +export default function SideChatPane(props: { + readonly parentRef: ScopedThreadRef; + readonly threadId: ThreadId; + readonly parentPanel: SideChatParentPanel; + readonly onClose: () => void; +}) { + const { parentRef, threadId } = props; + const threadRef = scopeThreadRef(parentRef.environmentId, threadId); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers the parent panel as host in the panel store + useEffect( + () => hostRightPanel(scopeThreadRef(parentRef.environmentId, threadId), parentRef), + [parentRef, threadId], + ); + const shell = useQuietThreadShell(threadRef); + const parent = useQuietThreadShell(parentRef); + const snapshotLoaded = useEnvironmentShellHasSnapshot(parentRef.environmentId); + const projection = useQuietThreadProjection(shell === null ? null : threadRef)?.projection; + const canOperate = useEnvironmentScope(parentRef.environmentId, AuthOrchestrationOperateScope); + const mergeBack = useAtomCommand(threadEnvironment.mergeBack, { reportFailure: false }); + const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const confirmThreadDelete = useClientSettings((settings) => settings.confirmThreadDelete); + const [bringingBack, setBringingBack] = useState(false); + const [deleting, setDeleting] = useState(false); + const mergeRun = projection ? resolveLatestMergeBackRun(projection) : null; + // A pending merge-back would fail the main thread's queued message, so the server refuses it. + const bringBackBlockedReason = !canOperate + ? "This connection cannot change threads." + : threadRuntimeIsActive(parent?.runtime) + ? "Available when the main thread is idle." + : mergeRun === null + ? "Available after a finished reply." + : null; + + const bringBack = async () => { + if (bringBackBlockedReason !== null || mergeRun === null || bringingBack) return; + setBringingBack(true); + const result = await mergeBack({ + environmentId: parentRef.environmentId, + input: { sourceThreadId: threadId, targetThreadId: parentRef.threadId, runId: mergeRun.id }, + }); + setBringingBack(false); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Brought back", + description: "The main thread gets this side chat with your next message there.", + }); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not bring back the side chat", + ...(error instanceof Error ? { description: error.message } : {}), + }); + } + }; + + const deleteSideChat = async () => { + if (!canOperate || shell === null || deleting) return; + if (confirmThreadDelete) { + const api = readLocalApi(); + if (!api) return; + const confirmed = await settlePromise(() => + api.dialogs.confirm( + [ + `Delete side chat "${shell.title}"?`, + "This permanently clears its conversation history.", + ].join("\n"), + { variant: "destructive" }, + ), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + setDeleting(true); + const result = await deleteThread({ + environmentId: parentRef.environmentId, + input: { threadId }, + }); + setDeleting(false); + if (result._tag === "Success") { + useRightPanelStore.getState().closeSurface(parentRef, `side-chat:${threadId}`); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not delete the side chat", + ...(error instanceof Error ? { description: error.message } : {}), + }); + } + }; + + return ( +
+
+ + {shell?.title ?? "Side chat"} + + + void bringBack()} + > + Bring back + + } + /> + + {bringBackBlockedReason ?? "Adds this side chat to the main thread's next message."} + + + + +
+ {shell === null ? ( + snapshotLoaded ? ( +

This side chat was deleted.

+ ) : null + ) : ( + + + + + + )} +
+ ); +} diff --git a/apps/web/src/components/chat/ThreadActivityBanners.tsx b/apps/web/src/components/chat/ThreadActivityBanners.tsx index 8bd26e21a04d..4c0af2d3472c 100644 --- a/apps/web/src/components/chat/ThreadActivityBanners.tsx +++ b/apps/web/src/components/chat/ThreadActivityBanners.tsx @@ -30,12 +30,12 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; import type { ThreadNowItem } from "./ThreadNow"; -/** Subagents waiting on the user, unless the parent is itself waiting on them. */ +/** Subagents and side chats waiting on the user, unless the parent is itself waiting on them. */ export function childInputNowItem(input: { readonly childThreadInputs: ReadonlyArray; readonly parentAwaitingUser: boolean; readonly onOpenThread: (threadId: ThreadId) => void; -}): ThreadNowItem | null { +}): (ThreadNowItem & { readonly waitingLabel: string }) | null { const { childThreadInputs, onOpenThread } = input; const first = childThreadInputs[0]; if (!first || input.parentAwaitingUser) return null; @@ -48,6 +48,9 @@ export function childInputNowItem(input: { ), title: childThreadInputsTitle(childThreadInputs), + waitingLabel: childThreadInputs.every((child) => child.lineage.sideChat === true) + ? "Side chat waiting" + : "Subagent waiting", description: childThreadInputs.map((child, index) => ( {index > 0 ? " " : null} diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index a5df136f1461..9ffbc90de753 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -78,10 +78,10 @@ export function ThreadDetailsCard({ const measure = () => { const frame = element.closest("[data-thread-details-card]"); const next = element.offsetHeight + (frame ? frame.offsetHeight - frame.clientHeight : 0); - // PR lists and lineage scroll as they expand. Counting them toward density would hide - // the section and workspace controls when the user asks to see more rows. + // PR lists, lineage and side chats scroll as they expand. Counting them toward density + // would hide the section and workspace controls when the user asks to see more rows. const lists = element.querySelectorAll( - "[data-thread-relationships-panel], [data-thread-details-pr-list]", + "[data-thread-relationships-panel], [data-thread-side-chats-panel], [data-thread-details-pr-list]", ); const fittingHeight = next - Array.from(lists).reduce((sum, list) => sum + list.offsetHeight, 0); diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index b8d96a33ec5a..5c648016ff50 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -249,10 +249,12 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { /> ) : null} - {density === "full" && !props.draftId && visible.lineage ? ( + {density === "full" && !props.draftId && (visible.lineage || visible.sideChats) ? ( ) : null} diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx index 88bd956decc2..959d0ac724f2 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.browser.test.tsx @@ -65,7 +65,12 @@ it.each([ setup(); await using screen = await render( - + , ); @@ -93,7 +98,12 @@ it.each([ setup(); await using screen = await render( - + , { router: "/environment/fork" }, ); diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index 63a8635c066f..b95ae4093a8a 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -49,6 +49,7 @@ import { ChevronDownIcon, CornerLeftUpIcon, GitForkIcon, + MessagesSquareIcon, MoreHorizontalIcon, PlusIcon, SquareIcon, @@ -71,6 +72,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { AgentElapsed } from "./AgentElapsed"; import { ThreadRelationshipIcon, threadRelationshipStatusLabel } from "./ThreadRelationshipIcon"; import { ThreadLineageWorkflowRow } from "./ThreadLineageWorkflowRow"; +import { openSideChatTab } from "../../lib/sideChatPane"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -349,6 +351,8 @@ function DelegatedFromRow(props: { export function ThreadRelationshipsPanel(props: { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; + readonly showLineage: boolean; + readonly showSideChats: boolean; }) { const ref = scopeThreadRef(props.environmentId, props.threadId); const projection = useThreadProjection(ref)?.projection ?? null; @@ -449,9 +453,12 @@ export function ThreadRelationshipsPanel(props: { related = [], active = [], previous = [], - } = groupBy(relationshipRows, ({ edge }) => { - if (edge.kind !== "subagent" || isParentThreadRelationship(edge, props.threadId)) - return "related"; + sideChats = [], + } = groupBy(relationshipRows, ({ threadId, edge }) => { + const isParent = isParentThreadRelationship(edge, props.threadId); + if (!isParent && graph.nodes.get(threadId)?.thread?.lineage.sideChat === true) + return "sideChats"; + if (edge.kind !== "subagent" || isParent) return "related"; return ["completed", "failed", "error", "cancelled", "interrupted", "idle"].includes( edge.status ?? "", ) @@ -476,16 +483,25 @@ export function ThreadRelationshipsPanel(props: { // A linked environment's agent may have delegated this thread from there. const delegatedFrom = currentThread?.delegatedFrom; - if ( - relationshipRows.length === 0 && - runningCount === 0 && - remoteSubagents.length === 0 && - delegatedFrom === undefined - ) { + const lineageVisible = + props.showLineage && + (related.length > 0 || + active.length > 0 || + previous.length > 0 || + runningCount > 0 || + remoteSubagents.length > 0 || + delegatedFrom !== undefined || + // Disconnect lives in this heading, so a thread with only side chats still shows it. + (canDetach && sideChats.length > 0)); + const sideChatsVisible = props.showSideChats && sideChats.length > 0; + const runningSideChats = sideChats.filter(({ edge }) => edge.status === "running").length; + + if (!lineageVisible && !sideChatsVisible) { return null; } const openThread = (threadId: ThreadId) => { + if (openSideChatTab(ref, graph.nodes.get(threadId)?.thread ?? null)) return; void navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(scopeThreadRef(props.environmentId, threadId)), @@ -572,327 +588,338 @@ export function ThreadRelationshipsPanel(props: { ? null : (graph.nodes.get(mergeTargetThreadId)?.thread?.title ?? null); - return ( - 0 ? `Lineage · ${runningCount} running` : "Lineage"} - // A lone "Previous agents" row names itself; the heading returns for status or actions. - showHeading={ - runningCount > 0 || - canDetach || - related.length > 0 || - active.length > 0 || - delegatedFrom !== undefined - } - data-thread-relationships-panel - actions={ - canDetach ? ( - - - } - > - - - - void detach()}> - - Disconnect agent session - - - - ) : null - } - > - {delegatedFrom === undefined ? null : ( - {}}> - - - )} - {groups.map((group) => ( - - {(visibleRows) => - visibleRows.map(({ threadId, edge }) => { - const node = graph.nodes.get(threadId); - const isSubagent = edge.kind === "subagent"; - const isMergeTarget = threadId === mergeTargetThreadId; - const isParent = isParentThreadRelationship(edge, props.threadId); - const rowStatus = - (isParent && threadId === parentRef?.threadId - ? (node?.thread?.activityRunStatus ?? parentNativeStatus) - : undefined) ?? threadRelationshipRowStatus(graph, { threadId, edge }); - const RelationshipIcon = isParent - ? CornerLeftUpIcon - : isSubagent - ? BotIcon - : GitForkIcon; - const relationship = relationshipLabel(edge, props.threadId); - const projectedAgent = - isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; - const workflowGroup = - projectedAgent === undefined - ? undefined - : workflowGroupsById.get(projectedAgent.id); - const agent = workflowGroup - ? projectedAgent - : currentSubagent(projectedAgent, node?.thread); - const failed = rowStatus === "failed" || rowStatus === "error"; - const canStop = - agent && - (agent.origin === "app_owned" || - (agent.origin === "provider_native" && - agent.driver === "claudeAgent" && - agent.runId != null && - agent.nativeTaskRef?.strength === "strong" && - agent.nativeTaskRef.nativeId !== null && - ["pending", "running", "waiting"].includes(agent.nativeStatus))) && - agent.startedAt && - ["pending", "running", "waiting"].includes(agent.status); - const threadTitle = relationshipThreadTitle({ - title: - workflowGroup?.workflow.workflowName ?? - node?.thread?.title ?? - agent?.title ?? - threadId, - isSubagent, - }); - const provider = providers?.find( - (entry) => - entry.instanceId === - (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), - ); - const providerDriver = agent?.driver ?? provider?.driver; - const canStopWorkflow = - canOperateWorkflow && - workflowGroup?.workflow.status === "running" && - agent?.origin === "provider_native" && - providerDriver === "claudeAgent"; - const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) - ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" - : ""; - const project = projects.find((project) => project.id === node?.thread?.projectId); - const relationshipHint = node?.missing - ? "This related thread is unavailable" - : `Open ${relationship.toLowerCase()} in this chat`; - const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; - const relationshipTooltip = agent ? ( - } - status={agent.status} - result={agent.result} - progress={agent.progress} - parentThread={currentThread ?? undefined} - childThread={node?.thread ?? undefined} - parentProject={currentProject} - childProject={project} - /> - ) : ( - relationshipHint - ); - const relationshipContent = ( - <> - { + const node = graph.nodes.get(threadId); + const isSubagent = edge.kind === "subagent"; + const isMergeTarget = threadId === mergeTargetThreadId; + const isParent = isParentThreadRelationship(edge, props.threadId); + const rowStatus = + (isParent && threadId === parentRef?.threadId + ? (node?.thread?.activityRunStatus ?? parentNativeStatus) + : undefined) ?? threadRelationshipRowStatus(graph, { threadId, edge }); + const RelationshipIcon = isParent + ? CornerLeftUpIcon + : isSubagent + ? BotIcon + : node?.thread?.lineage.sideChat === true + ? MessagesSquareIcon + : GitForkIcon; + const relationship = + node?.thread?.lineage.sideChat === true && !isParent + ? "Side chat" + : relationshipLabel(edge, props.threadId); + const projectedAgent = isSubagent && !isParent ? subagentsByThreadId.get(threadId) : undefined; + const workflowGroup = + projectedAgent === undefined ? undefined : workflowGroupsById.get(projectedAgent.id); + const agent = workflowGroup ? projectedAgent : currentSubagent(projectedAgent, node?.thread); + const failed = rowStatus === "failed" || rowStatus === "error"; + const canStop = + agent && + (agent.origin === "app_owned" || + (agent.origin === "provider_native" && + agent.driver === "claudeAgent" && + agent.runId != null && + agent.nativeTaskRef?.strength === "strong" && + agent.nativeTaskRef.nativeId !== null && + ["pending", "running", "waiting"].includes(agent.nativeStatus))) && + agent.startedAt && + ["pending", "running", "waiting"].includes(agent.status); + const threadTitle = relationshipThreadTitle({ + title: + workflowGroup?.workflow.workflowName ?? node?.thread?.title ?? agent?.title ?? threadId, + isSubagent, + }); + const provider = providers?.find( + (entry) => + entry.instanceId === (agent?.providerInstanceId ?? node?.thread?.providerInstanceId), + ); + const providerDriver = agent?.driver ?? provider?.driver; + const canStopWorkflow = + canOperateWorkflow && + workflowGroup?.workflow.status === "running" && + agent?.origin === "provider_native" && + providerDriver === "claudeAgent"; + const trailingVisibilityClass = (workflowGroup ? canStopWorkflow : canStop) + ? "group-hover:opacity-0 group-focus-within:opacity-0 pointer-coarse:opacity-0 [@media(hover:none)]:opacity-0" + : ""; + const project = projects.find((project) => project.id === node?.thread?.projectId); + const relationshipHint = node?.missing + ? "This related thread is unavailable" + : `Open ${relationship.toLowerCase()} in this chat`; + const RelationshipPopup = agent ? ThreadHoverCardPopup : TooltipPopup; + const relationshipTooltip = agent ? ( + } + status={agent.status} + result={agent.result} + progress={agent.progress} + parentThread={currentThread ?? undefined} + childThread={node?.thread ?? undefined} + parentProject={currentProject} + childProject={project} + /> + ) : ( + relationshipHint + ); + const relationshipContent = ( + <> + + + + {threadTitle} + + + {agent ? null : ( + + )} + {agent && !failed && (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( + + + + ) : !isMergeTarget ? ( + + {threadRelationshipStatusLabel(rowStatus)} + + ) : null} + + ); + const relationshipLink = ( + + openThread(threadId)} + /> + } + > + {relationshipContent} + + {relationshipTooltip} + + ); + if (workflowGroup && agent) { + return ( + openThread(memberThreadId as ThreadId)} + isThreadAvailable={(memberThreadId) => + graph.nodes.get(memberThreadId as ThreadId)?.missing === false + } + onStop={canStopWorkflow ? () => void stopWorkflow(agent.id) : undefined} + stopping={stoppingId === agent.id} + stopDisabled={stoppingId !== null} + header={relationshipLink} + /> + ); + } + return ( +
  • + {isMergeTarget ? ( +
    + + openThread(threadId)} /> - - - {threadTitle} - - - {agent ? null : ( - - )} - {agent && - !failed && - (deriveSubagentElapsedMs(agent, 0) !== null || canStopWorkflow) ? ( - - - - ) : !isMergeTarget ? ( - - {threadRelationshipStatusLabel(rowStatus)} - - ) : null} - - ); - const relationshipLink = ( - - openThread(threadId)} - /> + } + > + {relationshipContent} + + {relationshipTooltip} + + + + {threadRelationshipStatusLabel(rowStatus)} + +
    + ) : ( + relationshipLink + )} + {canStop && agent ? ( +
    + + void stopSubagent(threadId, agent)} /> - ); - } - return ( -
  • - {isMergeTarget ? ( -
    - - openThread(threadId)} - /> - } - > - {relationshipContent} - - {relationshipTooltip} - -
    - ) : ( - relationshipLink - )} - {canStop && agent ? ( -
    - - void stopSubagent(threadId, agent)} - /> - } - > - {stoppingId === threadId ? ( - - ) : ( - - )} - - Stop subagent - -
    - ) : null} -
  • - ); - }) + } + > + {stoppingId === threadId ? ( + + ) : ( + + )} + + Stop subagent + +
    + ) : null} + + ); + }; + + return ( + <> + {lineageVisible ? ( + 0 ? `Lineage · ${runningCount} running` : "Lineage"} + // A lone "Previous agents" row names itself; the heading returns for status or actions. + showHeading={ + runningCount > 0 || + canDetach || + related.length > 0 || + active.length > 0 || + delegatedFrom !== undefined } - - ))} - {remoteSubagents.length > 0 ? ( - {}}> - {remoteSubagents.map((agent) => ( - entry.instanceId === agent.providerInstanceId)} - /> + data-thread-relationships-panel + actions={ + canDetach ? ( + + + } + > + + + + void detach()}> + + Disconnect agent session + + + + ) : null + } + > + {delegatedFrom === undefined ? null : ( + {}}> + + + )} + {groups.map((group) => ( + + {(visibleRows) => visibleRows.map(renderRelationshipRow)} + ))} - + {remoteSubagents.length > 0 ? ( + {}}> + {remoteSubagents.map((agent) => ( + entry.instanceId === agent.providerInstanceId)} + /> + ))} + + ) : null} + ) : null} - + {sideChatsVisible ? ( + 0 ? `Side chats · ${runningSideChats} running` : "Side chats"} + data-thread-side-chats-panel + > + + {(visibleRows) => visibleRows.map(renderRelationshipRow)} + + + ) : null} + ); } diff --git a/apps/web/src/components/chat/backgroundThread.ts b/apps/web/src/components/chat/backgroundThread.ts index bab90534b5cd..b3dfee09a663 100644 --- a/apps/web/src/components/chat/backgroundThread.ts +++ b/apps/web/src/components/chat/backgroundThread.ts @@ -1,4 +1,7 @@ import { createContext } from "react"; -/** True for a thread kept mounted under Settings or Usage; its global shortcuts stand down. */ +/** + * True for a thread kept mounted under Settings or Usage, or shown as a side chat in its + * parent's panel; its global shortcuts and listeners stand down. + */ export const BackgroundThreadContext = createContext(false); diff --git a/apps/web/src/components/chat/composerSlashCommandSearch.ts b/apps/web/src/components/chat/composerSlashCommandSearch.ts index adc095abaebf..0c5c04e28cae 100644 --- a/apps/web/src/components/chat/composerSlashCommandSearch.ts +++ b/apps/web/src/components/chat/composerSlashCommandSearch.ts @@ -12,7 +12,7 @@ type SlashSearchItem = Extract< { type: "slash-command" | "provider-slash-command" | "skill" } >; -/** Providers expand slash commands only at the message start. */ +/** Provider commands, `/side` and `/btw` work only at the message start. */ export function slashCommandItemsForPromptPosition( items: ReadonlyArray, isAtPromptStart: boolean, @@ -20,7 +20,11 @@ export function slashCommandItemsForPromptPosition( if (isAtPromptStart) { return [...items]; } - return items.filter((item) => item.type !== "provider-slash-command"); + return items.filter( + (item) => + item.type === "skill" || + (item.type === "slash-command" && item.command !== "side" && item.command !== "btw"), + ); } function scoreSlashCommandItem(item: SlashSearchItem, query: string): number | null { diff --git a/apps/web/src/components/chat/threadDetailsItems.ts b/apps/web/src/components/chat/threadDetailsItems.ts index 9922b1b8a0b4..e6f94cc4e11c 100644 --- a/apps/web/src/components/chat/threadDetailsItems.ts +++ b/apps/web/src/components/chat/threadDetailsItems.ts @@ -11,6 +11,7 @@ export const THREAD_DETAILS_ITEMS = [ { id: "servers", label: "Servers", defaultVisible: true }, { id: "automations", label: "Automations", defaultVisible: true }, { id: "lineage", label: "Lineage", defaultVisible: true }, + { id: "sideChats", label: "Side chats", defaultVisible: true }, { id: "usage", label: "Usage limits", defaultVisible: false }, { id: "context", label: "Context window", defaultVisible: false }, ] as const; diff --git a/apps/web/src/components/chat/timelineViewport.ts b/apps/web/src/components/chat/timelineViewport.ts index 2dcbc4ae1d2f..527685285e0f 100644 --- a/apps/web/src/components/chat/timelineViewport.ts +++ b/apps/web/src/components/chat/timelineViewport.ts @@ -1,3 +1,4 @@ +import { isInSideChatPane } from "../../lib/sideChatPane"; import { registerCommands } from "../../shortcuts/dispatcher"; import { eventPathContainsSelector, @@ -86,6 +87,7 @@ export function attachTimelineViewport(scrollNode: HTMLElement): TimelineViewpor emit({ source: "pointer", onScrollbar: event.target === scrollNode }); // Clicking message text can leave DOM focus on body, and keyboard scrolling // bypasses wheel and pointer events, so scroll keys are heard app-wide. + // Body keys belong to the main timeline; a side chat's hears only its own. const handleKeyDown = (event: KeyboardEvent, consumed: boolean) => { const direction = UP_KEYS.has(event.key) ? "up" : DOWN_KEYS.has(event.key) ? "down" : null; if ( @@ -93,8 +95,8 @@ export function attachTimelineViewport(scrollNode: HTMLElement): TimelineViewpor consumed || !(event.target instanceof Node) || (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || + ((event.target !== document.body && event.target !== document.documentElement) || + isInSideChatPane(scrollNode))) || event.isComposing || event.altKey || event.ctrlKey || diff --git a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx index 64f56007ceaa..d04247d27979 100644 --- a/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx +++ b/apps/web/src/components/chat/useChatShortcuts.browser.test.tsx @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import type { KeybindingCommand, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { useEffect, useState } from "react"; import { expect, it } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; @@ -132,3 +133,57 @@ it.each([ expect(composerKeys).toEqual(composer); expect(received).toEqual(launcher); }); + +const ctrlShift = (key: string, command: KeybindingCommand) => ({ + command, + shortcut: { key, metaKey: false, ctrlKey: true, shiftKey: true, altKey: false, modKey: false }, +}); +const sideChatKeybindings: ResolvedKeybindingsConfig = [ + ctrlShift("m", "modelPicker.toggle"), + ctrlShift("x", "thread.stop"), +]; + +// A side chat in the main chat's panel: no window listeners, only its composer's own chords. +function SideChat(props: { toggles: string[]; stops: string[] }) { + const [composerElement, setComposerElement] = useState(null); + useChatShortcuts({ + enabled: false, + embedded: true, + composerElement, + keybindings: sideChatKeybindings, + scriptKeybindings: [], + getShortcutContext: () => shortcutContext, + threadFindActive: false, + composerRef: { + current: { + toggleModelPicker: () => props.toggles.push("model"), + } as Partial as ChatComposerHandle, + }, + branchToolbarRef: { current: null }, + closeThreadFind: () => {}, + commands: { "thread.stop": () => void props.stops.push("stop") }, + fallback: () => undefined, + }); + return ( + <> +
    +