From bb440c84b4ee703b2c6c601d421175698ccfac0c Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 30 Aug 2026 13:55:29 -0700 Subject: [PATCH 1/5] feat(web): identify cross-thread agent messages --- apps/server/src/cli/thread.test.ts | 26 +++++ apps/server/src/cli/thread.ts | 7 ++ .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.test.ts | 3 + .../Layers/ProjectionSnapshotQuery.ts | 6 ++ .../decider.projectScripts.test.ts | 55 ++++++++++- apps/server/src/orchestration/decider.ts | 16 ++++ apps/server/src/orchestration/projector.ts | 3 + .../Layers/ProjectionThreadMessages.test.ts | 2 + .../Layers/ProjectionThreadMessages.ts | 12 ++- apps/server/src/persistence/Migrations.ts | 2 + .../051_ProjectionThreadMessageSource.test.ts | 31 ++++++ .../051_ProjectionThreadMessageSource.ts | 16 ++++ .../Services/ProjectionThreadMessages.ts | 1 + apps/web/src/components/ChatView.tsx | 11 +++ .../src/components/ThreadStatusIndicators.tsx | 2 +- .../components/chat/MessagesTimeline.test.tsx | 49 +++++++++- .../src/components/chat/MessagesTimeline.tsx | 96 ++++++++++++++++++- docs/user/codex-thread-tools.md | 4 + .../src/state/threadReducer.test.ts | 2 + .../client-runtime/src/state/threadReducer.ts | 3 + packages/contracts/src/orchestration.test.ts | 44 +++++++++ packages/contracts/src/orchestration.ts | 4 + 23 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.test.ts create mode 100644 apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.ts diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 7eb2172e8291..dcb4afc1d883 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -622,6 +622,7 @@ it.effect("prepares and dispatches an exact accepted send using the target threa commandId: CommandId.make("command-send"), messageId: MessageId.make("message-send"), createdAt: "2026-08-22T00:00:00.000Z", + sourceThreadId: ThreadId.make("thread-source"), }); assert.deepStrictEqual(result, { @@ -643,12 +644,37 @@ it.effect("prepares and dispatches an exact accepted send using the target threa }, runtimeMode: "approval-required", interactionMode: "plan", + sourceThreadId: "thread-source", createdAt: "2026-08-22T00:00:00.000Z", }, ]); }), ); +it.effect("does not mark a send to the current thread as cross-thread", () => + Effect.gen(function* () { + const { source } = runnerSource(); + const dispatched: unknown[] = []; + yield* sendThreadOutput( + { + descriptor: source.descriptor, + shell: source.shell, + dispatch: (command) => Effect.sync(() => dispatched.push(command)), + }, + { + identifier: "thread-runner", + message: "status", + commandId: CommandId.make("command-self-send"), + messageId: MessageId.make("message-self-send"), + createdAt: "2026-08-22T00:00:00.000Z", + sourceThreadId: ThreadId.make("thread-runner"), + }, + ); + + assert.notProperty(dispatched[0] as object, "sourceThreadId"); + }), +); + it.effect("marks only explicitly tracked sends for wait correlation", () => Effect.gen(function* () { const { source } = runnerSource(); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 8f1dc2026280..e3a413537d57 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -512,6 +512,7 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( readonly commandId: CommandId; readonly messageId: MessageId; readonly createdAt: string; + readonly sourceThreadId?: ThreadId; readonly trackRequestCorrelation?: true; readonly rejectWaitForThreadId?: ThreadId; }, @@ -558,6 +559,9 @@ export const sendThreadOutput = Effect.fn("sendThreadOutput")(function* ( }, runtimeMode: resolution.thread.runtimeMode, interactionMode: resolution.thread.interactionMode, + ...(input.sourceThreadId !== undefined && input.sourceThreadId !== resolution.thread.id + ? { sourceThreadId: input.sourceThreadId } + : {}), ...(input.trackRequestCorrelation === true ? { trackRequestCorrelation: true } : {}), createdAt: input.createdAt, }); @@ -946,6 +950,9 @@ const runThreadSend = Effect.fn("runThreadSend")(function* ( commandId, messageId, createdAt: DateTime.formatIso(yield* DateTime.now), + ...(process.env.T3CODE_THREAD_ID?.trim() + ? { sourceThreadId: ThreadId.make(process.env.T3CODE_THREAD_ID.trim()) } + : {}), ...(waitForCompletion ? { trackRequestCorrelation: true as const } : {}), ...(waitForCompletion && process.env.T3CODE_THREAD_ID?.trim() ? { rejectWaitForThreadId: ThreadId.make(process.env.T3CODE_THREAD_ID.trim()) } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 4da34eca72ae..e6b05e968485 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1083,6 +1083,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti role: event.payload.role, text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), + ...(event.payload.sourceThreadId !== undefined || + previousMessage?.sourceThreadId !== undefined + ? { sourceThreadId: event.payload.sourceThreadId ?? previousMessage?.sourceThreadId } + : {}), isStreaming: event.payload.streaming, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6c6f8f662c41..2556d88f82ac 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -130,6 +130,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { turn_id, role, text, + source_thread_id, is_streaming, created_at, updated_at @@ -140,6 +141,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 'turn-1', 'assistant', 'hello from projection', + 'thread-source', 0, '2026-02-24T00:00:04.000Z', '2026-02-24T00:00:05.000Z' @@ -354,6 +356,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: asMessageId("message-1"), role: "assistant", text: "hello from projection", + sourceThreadId: ThreadId.make("thread-source"), turnId: asTurnId("turn-1"), streaming: false, createdAt: "2026-02-24T00:00:04.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3ab5e3b503db..5c5a2905e63d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -87,6 +87,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + sourceThreadId: Schema.NullOr(ThreadId), }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; @@ -560,6 +561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -1008,6 +1010,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -1255,6 +1258,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -1597,6 +1601,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { role: row.role, text: row.text, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, createdAt: row.createdAt, @@ -2699,6 +2704,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.messageId, role: row.role, text: row.text, + ...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, createdAt: row.createdAt, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index bf5c509fa16b..05779b7a8d3b 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -271,7 +271,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { updatedAt: now, }, }); - const readModel = yield* projectEvent(withProject, { + const withTargetThread = yield* projectEvent(withProject, { sequence: 2, eventId: asEventId("evt-thread-create"), aggregateKind: "thread", @@ -298,6 +298,33 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { updatedAt: now, }, }); + const readModel = yield* projectEvent(withTargetThread, { + sequence: 3, + eventId: asEventId("evt-source-thread-create"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-source"), + type: "thread.created", + occurredAt: now, + commandId: CommandId.make("cmd-source-thread-create"), + causationEventId: null, + correlationId: CommandId.make("cmd-source-thread-create"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-source"), + projectId: asProjectId("project-1"), + title: "Source thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); const result = yield* decideOrchestrationCommand({ command: { @@ -316,6 +343,7 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { ]), interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", + sourceThreadId: ThreadId.make("thread-source"), createdAt: now, }, readModel, @@ -325,6 +353,9 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { const events = Array.isArray(result) ? result : [result]; expect(events).toHaveLength(2); expect(events[0]?.type).toBe("thread.message-sent"); + if (events[0]?.type === "thread.message-sent") { + expect(events[0].payload.sourceThreadId).toBe("thread-source"); + } const turnStartEvent = events[1]; expect(turnStartEvent?.type).toBe("thread.turn-start-requested"); expect(turnStartEvent?.causationEventId).toBe(events[0]?.eventId ?? null); @@ -340,6 +371,28 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { ]), runtimeMode: "approval-required", }); + + const selfSourceFailure = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-self-source"), + threadId: ThreadId.make("thread-1"), + sourceThreadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-self-source"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }, + readModel, + }), + ); + expect(selfSourceFailure.message).toContain("must differ from its target thread"); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 774d92a64311..9afc8c00c02e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1367,6 +1367,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); + if (command.sourceThreadId === command.threadId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "A cross-thread message source must differ from its target thread.", + }); + } + if (command.sourceThreadId !== undefined) { + yield* requireThread({ + readModel, + command, + threadId: command.sourceThreadId, + }); + } const sourceProposedPlan = command.sourceProposedPlan; const sourceThread = sourceProposedPlan ? yield* requireThread({ @@ -1405,6 +1418,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" role: command.message.role, text: command.message.text, attachments: command.message.attachments, + ...(command.sourceThreadId !== undefined + ? { sourceThreadId: command.sourceThreadId } + : {}), turnId: null, streaming: false, createdAt: command.createdAt, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 984dc93cd1b8..f4a79b0f731b 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -577,6 +577,9 @@ export function projectEvent( role: payload.role, text: payload.text, ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), + ...(payload.sourceThreadId !== undefined + ? { sourceThreadId: payload.sourceThreadId } + : {}), turnId: payload.turnId, streaming: payload.streaming, createdAt: payload.createdAt, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index b1f394a9e577..d7b6bc11a80c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -36,6 +36,7 @@ layer("ProjectionThreadMessageRepository", (it) => { role: "user", text: "initial", attachments: persistedAttachments, + sourceThreadId: ThreadId.make("thread-source"), isStreaming: false, createdAt, updatedAt, @@ -62,6 +63,7 @@ layer("ProjectionThreadMessageRepository", (it) => { if (rowById._tag === "Some") { assert.equal(rowById.value.text, "updated"); assert.deepEqual(rowById.value.attachments, persistedAttachments); + assert.equal(rowById.value.sourceThreadId, "thread-source"); } }), ); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..4ff6e4483a80 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ChatAttachment } from "@t3tools/contracts"; +import { ChatAttachment, ThreadId } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -21,6 +21,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + sourceThreadId: Schema.NullOr(ThreadId), }), ); @@ -37,6 +38,7 @@ function toProjectionThreadMessage( createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.sourceThreadId !== null ? { sourceThreadId: row.sourceThreadId } : {}), }; } @@ -56,6 +58,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json, + source_thread_id, is_streaming, created_at, updated_at @@ -74,6 +77,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { WHERE message_id = ${row.messageId} ) ), + ${row.sourceThreadId ?? null}, ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -88,6 +92,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { excluded.attachments_json, projection_thread_messages.attachments_json ), + source_thread_id = COALESCE( + excluded.source_thread_id, + projection_thread_messages.source_thread_id + ), is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at @@ -107,6 +115,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -128,6 +137,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { role, text, attachments_json AS "attachments", + source_thread_id AS "sourceThreadId", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 02df09d6669d..5447d209ad68 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -63,6 +63,7 @@ import Migration0047 from "./Migrations/047_ProjectionTurnRequestCorrelations.ts import Migration0048 from "./Migrations/048_ProjectionThreadWorktreeCleanup.ts"; import Migration0049 from "./Migrations/049_ProjectionThreadLinkedPullRequest.ts"; import Migration0050 from "./Migrations/050_ProjectionThreadsUnsettledAt.ts"; +import Migration0051 from "./Migrations/051_ProjectionThreadMessageSource.ts"; /** * Migration loader with all migrations defined inline. @@ -125,6 +126,7 @@ export const migrationEntries = [ [48, "ProjectionThreadWorktreeCleanup", Migration0048], [49, "ProjectionThreadLinkedPullRequest", Migration0049], [50, "ProjectionThreadsUnsettledAt", Migration0050], + [51, "ProjectionThreadMessageSource", Migration0051], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.test.ts b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.test.ts new file mode 100644 index 000000000000..5e27f9f4fa89 --- /dev/null +++ b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("051_ProjectionThreadMessageSource", (it) => { + it.effect("adds nullable source thread provenance to projected messages", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 50 }); + const before = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + assert.isFalse(before.some((column) => column.name === "source_thread_id")); + + const executed = yield* runMigrations({ toMigrationInclusive: 51 }); + assert.deepStrictEqual(executed, [[51, "ProjectionThreadMessageSource"]]); + + const after = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + assert.equal(after.filter((column) => column.name === "source_thread_id").length, 1); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.ts b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.ts new file mode 100644 index 000000000000..14410b25199f --- /dev/null +++ b/apps/server/src/persistence/Migrations/051_ProjectionThreadMessageSource.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + + if (!columns.some((column) => column.name === "source_thread_id")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN source_thread_id TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..9559629af63e 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -28,6 +28,7 @@ export const ProjectionThreadMessage = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + sourceThreadId: Schema.optional(ThreadId), isStreaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index df2c61a7eae8..aeaff1d20cdc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1863,6 +1863,16 @@ function ChatViewContent(props: ChatViewProps) { : null; const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const clientSettingsHydrated = useClientSettingsHydrated(); + const openAgentMessageSourceThread = useCallback( + (threadId: ThreadId) => { + if (!activeThread) return; + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, threadId)), + }); + }, + [activeThread, navigate], + ); const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< ReadonlyMap> >(() => new Map()); @@ -7405,6 +7415,7 @@ function ChatViewContent(props: ChatViewProps) { runningTurnId={activeRunningTurnId} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} + onOpenSourceThread={openAgentMessageSourceThread} routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 9c8a28209a2d..9b43251f0d3c 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -467,7 +467,7 @@ export function ThreadStatusLabel({ status, compact = false, }: { - status: ThreadStatusPill; + status: Pick & { label: string }; compact?: boolean; }) { if (compact) { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0bdccab23133..eb927fc800cc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,4 +1,4 @@ -import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; +import { CheckpointRef, EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; import { formatActionResumeFollowUp } from "@t3tools/shared/actionResume"; import { createRef, type ReactNode, type Ref } from "react"; @@ -127,6 +127,26 @@ vi.mock("@pierre/diffs/react", () => { return { FileDiff: MockFileDiff }; }); +vi.mock("../../state/entities", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useThreadShell: () => ({ + id: ThreadId.make("thread-source"), + title: "Mobile Reconnect Issue", + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: null, + session: { status: "running" }, + backgroundLiveness: null, + actionResume: null, + worktreeCleanup: null, + }), + }; +}); + function matchMedia() { return { matches: false, @@ -238,6 +258,33 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { + it("renders cross-thread posts as left-aligned agent message cards", () => { + const sourceThreadId = ThreadId.make("thread-source"); + const entry = buildUserTimelineEntry("The reconnect fix is ready for review."); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("AGENT MESSAGE"); + expect(markup).toContain("Mobile Reconnect Issue"); + expect(markup).toContain("Working"); + expect(markup).toContain("items-start"); + expect(markup).toContain("max-w-[80%]"); + expect(markup).toContain("border-primary/55 bg-secondary"); + expect(markup).toContain("font-medium lowercase text-primary"); + expect(markup).toContain('aria-label="Open source thread: Mobile Reconnect Issue"'); + expect(markup).not.toContain("Open source thread"); + expect(markup).not.toContain("bg-message p-3"); + }); + it("shows compact completed Action results without embedding detailed output", () => { const actionText = formatActionResumeFollowUp({ actionName: "Run Full CI", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8b9908af56df..b8d69c8c8e95 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -5,9 +5,10 @@ import { type ScopedThreadRef, type ServerProviderSkill, type ThreadAnnotation, + type ThreadId, type TurnId, } from "@t3tools/contracts"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { AgentPanelModel } from "@t3tools/client-runtime/state/subagentRuntime"; import { emptyAgentPanelModel, @@ -16,6 +17,7 @@ import { const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; +const NOOP_OPEN_SOURCE_THREAD = (_threadId: ThreadId) => {}; const NOOP_DOWNLOAD_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { actionResultPresentation, parseActionResumeFollowUp } from "@t3tools/shared/actionResume"; @@ -55,6 +57,7 @@ import { } from "../../lib/diffRendering"; import ChatMarkdown from "../ChatMarkdown"; import { + ArrowUpRightIcon, BotIcon, CheckIcon, ChevronDownIcon, @@ -135,6 +138,10 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { resolveThreadStatusPill } from "../Sidebar.logic"; +import { ThreadStatusLabel } from "../ThreadStatusIndicators"; +import { useClientSettings } from "../../hooks/useSettings"; +import { useThreadShell } from "../../state/entities"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -158,6 +165,7 @@ interface TimelineRowSharedState { workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; + onOpenSourceThread: (threadId: ThreadId) => void; onRevertUserMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onFileDownload: (attachment: ChatFileAttachment) => void; @@ -244,6 +252,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; onFileDownload?: (attachment: ChatFileAttachment) => void; activeThreadEnvironmentId: EnvironmentId; + onOpenSourceThread?: (threadId: ThreadId) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; @@ -296,6 +305,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, onFileDownload = NOOP_DOWNLOAD_ATTACHMENT, activeThreadEnvironmentId, + onOpenSourceThread = NOOP_OPEN_SOURCE_THREAD, markdownCwd, resolvedTheme, timestampFormat, @@ -576,6 +586,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + onOpenSourceThread, onRevertUserMessage, onImageExpand, onFileDownload, @@ -595,6 +606,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, + onOpenSourceThread, onRevertUserMessage, onImageExpand, onFileDownload, @@ -1206,7 +1218,16 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "work-live" ? : null} {row.kind === "work-toggle" ? : null} {row.kind === "turn-fold" ? : null} - {row.kind === "message" && row.message.role === "user" ? : null} + {row.kind === "message" && + row.message.role === "user" && + row.message.sourceThreadId !== undefined ? ( + + ) : null} + {row.kind === "message" && + row.message.role === "user" && + row.message.sourceThreadId === undefined ? ( + + ) : null} {row.kind === "message" && row.message.role === "assistant" ? ( ) : null} @@ -1221,6 +1242,77 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ); }); +function AgentMessageTimelineRow({ + row, + sourceThreadId, +}: { + row: Extract; + sourceThreadId: ThreadId; +}) { + const ctx = use(TimelineRowCtx); + + const source = useThreadShell(scopeThreadRef(ctx.activeThreadEnvironmentId, sourceThreadId)); + const compactStatus = useClientSettings((settings) => settings.compactLegacySidebarStatuses); + const status = source + ? (resolveThreadStatusPill({ thread: source }) ?? + (source.session?.status === "error" + ? { + label: "Failed", + colorClass: "text-red-700 dark:text-red-300", + dotClass: "bg-red-600 dark:bg-red-300", + pulse: false, + } + : { + label: "Ready", + colorClass: "text-muted-foreground", + dotClass: "bg-muted-foreground/55", + pulse: false, + })) + : null; + const sourceTitle = source?.title ?? "source thread"; + + return ( +
+
+
+ + AGENT MESSAGE + + + {status ? : null} + + + } + > + {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} + + + {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)} + + +
+ +
+
+ ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); // The attachment union has an open member, so guards (not literal type diff --git a/docs/user/codex-thread-tools.md b/docs/user/codex-thread-tools.md index 7815e006fb09..3d15e1a751c1 100644 --- a/docs/user/codex-thread-tools.md +++ b/docs/user/codex-thread-tools.md @@ -33,6 +33,10 @@ confirms that LastCode persisted the request, not that the provider finished it. oversized messages, missing or ambiguous targets, authorization failures, and rejected dispatches fail without reporting acceptance. +Messages sent from one LastCode thread to another appear in the target conversation as agent +message cards. The card links back to the source thread and shows its current status. A plain +`send` to the caller's own thread remains a normal user message. + Add `--wait` when the caller needs the exact resulting turn rather than dispatch acceptance. `send --wait` cannot target the caller's current thread because that queued turn cannot begin until the current command returns; use plain `send` for a self-directed follow-up. diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index c990466fa15e..19f069d0a330 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -396,6 +396,7 @@ describe("applyThreadDetailEvent", () => { messageId: MessageId.make("msg-1"), role: "user", text: "Hello, world!", + sourceThreadId: ThreadId.make("thread-source"), turnId: null, streaming: false, createdAt: "2026-04-01T06:00:00.000Z", @@ -407,6 +408,7 @@ describe("applyThreadDetailEvent", () => { if (result.kind === "updated") { expect(result.thread.messages).toHaveLength(1); expect(result.thread.messages[0]?.text).toBe("Hello, world!"); + expect(result.thread.messages[0]?.sourceThreadId).toBe("thread-source"); } }); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index fa7e1ce6f37e..c4939da298c5 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -312,6 +312,9 @@ export function applyThreadDetailEvent( ...(event.payload.attachments !== undefined ? { attachments: event.payload.attachments } : {}), + ...(event.payload.sourceThreadId !== undefined + ? { sourceThreadId: event.payload.sourceThreadId } + : {}), turnId: event.payload.turnId, streaming: event.payload.streaming, createdAt: event.payload.createdAt, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 2da7acf3a2b3..87d08459af8a 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -318,6 +318,50 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () }), ); +it.effect("preserves cross-thread message provenance", () => + Effect.gen(function* () { + const sourceThreadId = "thread-source"; + const command = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-cross-thread", + threadId: "thread-target", + sourceThreadId, + message: { + messageId: "msg-cross-thread", + role: "user", + text: "status update", + attachments: [], + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + const message = yield* decodeOrchestrationMessage({ + id: "msg-cross-thread", + role: "user", + text: "status update", + sourceThreadId, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const payload = yield* decodeThreadMessageSentPayload({ + threadId: "thread-target", + messageId: "msg-cross-thread", + role: "user", + text: "status update", + sourceThreadId, + turnId: null, + streaming: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + assert.strictEqual(command.sourceThreadId, sourceThreadId); + assert.strictEqual(message.sourceThreadId, sourceThreadId); + assert.strictEqual(payload.sourceThreadId, sourceThreadId); + }), +); + it.effect("accepts inline images, uploaded images, and uploaded files from clients", () => Effect.gen(function* () { const command = yield* decodeClientOrchestrationCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index d169282fbb9c..22ea1232ef39 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -517,6 +517,7 @@ export const OrchestrationMessage = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + sourceThreadId: Schema.optional(ThreadId), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, createdAt: IsoDateTime, @@ -1224,6 +1225,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), ), bootstrap: Schema.optional(ThreadTurnStartBootstrap), + sourceThreadId: Schema.optional(ThreadId), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), trackRequestCorrelation: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, @@ -1244,6 +1246,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, bootstrap: Schema.optional(ThreadTurnStartBootstrap), + sourceThreadId: Schema.optional(ThreadId), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), trackRequestCorrelation: Schema.optional(Schema.Literal(true)), createdAt: IsoDateTime, @@ -1691,6 +1694,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ role: OrchestrationMessageRole, text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), + sourceThreadId: Schema.optional(ThreadId), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, createdAt: IsoDateTime, From ea7be2e4d69fb6b746abb932d7485e9db8f1a897 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 30 Aug 2026 14:10:49 -0700 Subject: [PATCH 2/5] fix(chat): complete cross-thread agent cards --- .../src/features/threads/ThreadFeed.tsx | 145 ++++++++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 55 +++++-- .../src/components/chat/MessagesTimeline.tsx | 24 +-- 3 files changed, 202 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 607c32920464..7142e22da5c1 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3,6 +3,7 @@ import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { actionResultPresentation, @@ -114,8 +115,10 @@ import { } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { useAssetUrl, useAssetUrlState } from "../../state/assets"; +import { useThreadShell } from "../../state/entities"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; +import { resolveThreadStatus } from "./threadPresentation"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", @@ -980,6 +983,7 @@ function renderFeedEntry( readonly onToggleActionFollowUp: (rowId: string) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly onOpenSourceThread: (sourceThreadId: ThreadId) => void; readonly renderMarkdownImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; @@ -1074,6 +1078,25 @@ function renderFeedEntry( !assistantTurnStillInProgress && !message.streaming; + if (isUser && message.sourceThreadId !== undefined) { + return ( + + ); + } + if (isUser) { const enterAnimated = isFreshTimestamp(message.createdAt); return ( @@ -1206,6 +1229,117 @@ function renderFeedEntry( ); } +function AgentMessageTimelineRow(props: { + readonly entry: Extract; + readonly environmentId: EnvironmentId; + readonly iconSubtleColor: ColorValue; + readonly markdownStyles: MarkdownStyleSet; + readonly maxWidth: number; + readonly onLinkPress: (href: string) => void; + readonly onOpenSourceThread: (sourceThreadId: ThreadId) => void; + readonly onPressImage: (uri: string, headers?: Record) => void; + readonly renderImage: MarkdownImageRenderer; + readonly reviewCommentColors: ReviewCommentColors; + readonly skills?: ReadonlyArray; + readonly sourceThreadId: ThreadId; +}) { + const source = useThreadShell(scopeThreadRef(props.environmentId, props.sourceThreadId)); + const resolvedStatus = source ? resolveThreadStatus(source) : null; + const status = source + ? (resolvedStatus ?? { + label: "Ready", + iconColor: props.iconSubtleColor, + }) + : null; + const sourceTitle = source?.title ?? "Source thread unavailable"; + const message = props.entry.message; + const attachments = (message.attachments ?? []).filter( + (attachment) => attachment.type === "image", + ); + + return ( + + + + + AGENT MESSAGE + + {status ? ( + + + {status.label} + + ) : null} + {source ? ( + props.onOpenSourceThread(props.sourceThreadId)} + > + + {sourceTitle.toLocaleLowerCase()} + + + + ) : ( + + {sourceTitle.toLocaleLowerCase()} + + )} + + {formatMessageTime(message.createdAt)} + + + {message.text.trim().length > 0 ? ( + + ) : null} + {attachments.map((attachment) => ( + + ))} + + {message.text.trim().length > 0 ? ( + + + + ) : null} + + ); +} + const ActionFollowUpCard = memo(function ActionFollowUpCard(props: { readonly actionName: string; readonly outcome: ActionResultPresentationOutcome; @@ -1731,6 +1865,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.environmentId, props.threadId, props.workspaceRoot, navigation], ); + const onOpenSourceThread = useCallback( + (sourceThreadId: ThreadId) => { + navigation.navigate("Thread", { + environmentId: String(props.environmentId), + threadId: String(sourceThreadId), + }); + }, + [navigation, props.environmentId], + ); const renderMarkdownImage = useCallback( (image) => { const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); @@ -2167,6 +2310,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleActionFollowUp, onPressImage, onMarkdownLinkPress, + onOpenSourceThread, renderMarkdownImage, iconSubtleColor, userBubbleColor, @@ -2190,6 +2334,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, + onOpenSourceThread, onPressImage, onToggleTurnFold, onToggleActionFollowUp, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index eb927fc800cc..9eb02916f487 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -6,6 +6,8 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; +const threadShellMockState = vi.hoisted(() => ({ available: true })); + vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -131,19 +133,22 @@ vi.mock("../../state/entities", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useThreadShell: () => ({ - id: ThreadId.make("thread-source"), - title: "Mobile Reconnect Issue", - hasActionableProposedPlan: false, - hasPendingApprovals: false, - hasPendingUserInput: false, - interactionMode: "default", - latestTurn: null, - session: { status: "running" }, - backgroundLiveness: null, - actionResume: null, - worktreeCleanup: null, - }), + useThreadShell: () => + threadShellMockState.available + ? { + id: ThreadId.make("thread-source"), + title: "Mobile Reconnect Issue", + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default", + latestTurn: null, + session: { status: "running" }, + backgroundLiveness: null, + actionResume: null, + worktreeCleanup: null, + } + : null, }; }); @@ -285,6 +290,30 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("bg-message p-3"); }); + it("does not link to a source thread that is no longer available", () => { + threadShellMockState.available = false; + try { + const sourceThreadId = ThreadId.make("thread-source"); + const entry = buildUserTimelineEntry("The reconnect fix is ready for review."); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("source thread unavailable"); + expect(markup).not.toContain("Open source thread:"); + } finally { + threadShellMockState.available = true; + } + }); + it("shows compact completed Action results without embedding detailed output", () => { const actionText = formatActionResumeFollowUp({ actionName: "Run Full CI", diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b8d69c8c8e95..7f0537358a40 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1282,15 +1282,21 @@ function AgentMessageTimelineRow({ · {status ? : null} - + {source ? ( + + ) : ( + + source thread unavailable + + )} } From 2fcc91b73a82773be151233c646ebce3fb5ef73b Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 30 Aug 2026 14:20:17 -0700 Subject: [PATCH 3/5] fix(web): preserve agent message interactions --- .../components/chat/MessagesTimeline.test.tsx | 42 ++- .../src/components/chat/MessagesTimeline.tsx | 244 +++++++++++------- 2 files changed, 177 insertions(+), 109 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 9eb02916f487..50793c837884 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -161,6 +161,7 @@ function matchMedia() { } let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; +let AgentMessageSourceTitle: typeof import("./MessagesTimeline").AgentMessageSourceTitle; beforeAll(async () => { const classList = { @@ -194,7 +195,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline } = await import("./MessagesTimeline")); + ({ MessagesTimeline, AgentMessageSourceTitle } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -263,7 +264,7 @@ function buildAssistantTimelineEntry(text: string) { } describe("MessagesTimeline", () => { - it("renders cross-thread posts as left-aligned agent message cards", () => { + it("renders cross-thread posts as agent messages with their attachments", () => { const sourceThreadId = ThreadId.make("thread-source"); const entry = buildUserTimelineEntry("The reconnect fix is ready for review."); const markup = renderToStaticMarkup( @@ -272,7 +273,20 @@ describe("MessagesTimeline", () => { timelineEntries={[ { ...entry, - message: { ...entry.message, sourceThreadId }, + message: { + ...entry.message, + sourceThreadId, + attachments: [ + { + type: "file" as const, + id: "attachment-report-pdf", + name: "handoff.pdf", + mimeType: "application/pdf", + sizeBytes: 42, + previewUrl: "https://environment.test/api/assets/handoff.pdf", + }, + ], + }, }, ]} />, @@ -281,13 +295,23 @@ describe("MessagesTimeline", () => { expect(markup).toContain("AGENT MESSAGE"); expect(markup).toContain("Mobile Reconnect Issue"); expect(markup).toContain("Working"); - expect(markup).toContain("items-start"); - expect(markup).toContain("max-w-[80%]"); - expect(markup).toContain("border-primary/55 bg-secondary"); - expect(markup).toContain("font-medium lowercase text-primary"); expect(markup).toContain('aria-label="Open source thread: Mobile Reconnect Issue"'); - expect(markup).not.toContain("Open source thread"); - expect(markup).not.toContain("bg-message p-3"); + expect(markup).toContain('download="handoff.pdf"'); + }); + + it("opens the source thread when its title is activated", () => { + const sourceThreadId = ThreadId.make("thread-source"); + const onOpenSourceThread = vi.fn(); + const sourceTitle = AgentMessageSourceTitle({ + sourceThreadId, + sourceTitle: "Mobile Reconnect Issue", + onOpenSourceThread, + }); + + expect(sourceTitle.type).toBe("button"); + sourceTitle.props.onClick?.(); + expect(onOpenSourceThread).toHaveBeenCalledOnce(); + expect(onOpenSourceThread).toHaveBeenCalledWith(sourceThreadId); }); it("does not link to a source thread that is no longer available", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7f0537358a40..4a83966f856d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -45,6 +45,8 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { + type ChatAttachment, + type ChatFileAttachment as WebChatFileAttachment, type ChatImageAttachment, isFileAttachment, isImageAttachment, @@ -1270,6 +1272,11 @@ function AgentMessageTimelineRow({ })) : null; const sourceTitle = source?.title ?? "source thread"; + const images = (row.message.attachments ?? []).filter(isImageAttachment); + const files = (row.message.attachments ?? []).filter(isFileAttachment); + const unknownAttachments = (row.message.attachments ?? []).filter( + (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment), + ); return (
@@ -1282,21 +1289,11 @@ function AgentMessageTimelineRow({ · {status ? : null} - {source ? ( - - ) : ( - - source thread unavailable - - )} + } @@ -1308,6 +1305,7 @@ function AgentMessageTimelineRow({
+ void; +}) { + return sourceTitle ? ( + + ) : ( + + source thread unavailable + + ); +} + +function MessageAttachments({ + images, + files, + unknown, + children, +}: { + images: ReadonlyArray; + files: ReadonlyArray; + unknown: ReadonlyArray; + children?: ReactNode; +}) { + const ctx = use(TimelineRowCtx); + + return ( + <> + {images.length > 0 ? ( +
+ {images.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ ) : null} + {children} + {files.length > 0 || unknown.length > 0 ? ( +
+ {files.map((file) => { + const content = ( + <> + + {file.name} + {file.downloadable === false ? null : } + + ); + return file.previewUrl ? ( + + {content} + + ) : file.downloadable === false ? ( +
+ {content} +
+ ) : ( + + ); + })} + {unknown.map((attachment) => ( +
+ + {attachment.name} +
+ ))} +
+ ) : null} + + ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); // The attachment union has an open member, so guards (not literal type @@ -1350,91 +1470,15 @@ function UserTimelineRow({ row }: { row: Extract
- {regularImages.length > 0 && ( -
- {regularImages.map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {previewAnnotations.map((annotation, index) => ( - - ))} - {userFiles.length > 0 || unknownAttachments.length > 0 ? ( -
- {userFiles.map((file) => { - const content = ( - <> - - {file.name} - {file.downloadable === false ? null : ( - - )} - - ); - return file.previewUrl ? ( - - {content} - - ) : file.downloadable === false ? ( -
- {content} -
- ) : ( - - ); - })} - {unknownAttachments.map((attachment) => ( -
- - {attachment.name} -
- ))} -
- ) : null} + + {previewAnnotations.map((annotation, index) => ( + + ))} + {elementContexts.length > 0 ? (
{elementContexts.map((context) => ( From 63e286bc166d3e4af97df63ad3368eab06201598 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 30 Aug 2026 14:34:01 -0700 Subject: [PATCH 4/5] test(web): mount agent message navigation --- apps/web/package.json | 1 + apps/web/src/components/ChatView.tsx | 11 +- .../components/chat/MessagesTimeline.test.tsx | 107 +++++++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 2 +- apps/web/src/threadRoutes.ts | 10 ++ pnpm-lock.yaml | 96 ++++++++++++---- 6 files changed, 182 insertions(+), 45 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 2e0d334934be..e285634cd091 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -65,6 +65,7 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "happy-dom": "^20.12.0", "msw": "2.12.11", "tailwindcss": "^4.0.0", "vite": "catalog:", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeaff1d20cdc..3a90a6584530 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -230,7 +230,11 @@ import { selectProjectGroupingSettings, } from "../logicalProject"; import { buildPhysicalToLogicalProjectKeyMap } from "../sidebarProjectGrouping"; -import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; +import { + buildDraftThreadRouteParams, + buildThreadRouteLocation, + buildThreadRouteParams, +} from "../threadRoutes"; import { beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, @@ -1866,10 +1870,7 @@ function ChatViewContent(props: ChatViewProps) { const openAgentMessageSourceThread = useCallback( (threadId: ThreadId) => { if (!activeThread) return; - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(scopeThreadRef(activeThread.environmentId, threadId)), - }); + void navigate(buildThreadRouteLocation(scopeThreadRef(activeThread.environmentId, threadId))); }, [activeThread, navigate], ); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 50793c837884..df844d83f83b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,10 +1,14 @@ import { CheckpointRef, EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { formatActionResumeFollowUp } from "@t3tools/shared/actionResume"; -import { createRef, type ReactNode, type Ref } from "react"; +import { act, createRef, type ReactNode, type Ref } from "react"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; +import { Window } from "happy-dom"; +import { buildThreadRouteLocation } from "../../threadRoutes"; const threadShellMockState = vi.hoisted(() => ({ available: true })); @@ -160,8 +164,46 @@ function matchMedia() { }; } +function installBrowserGlobals(browser: Window) { + const values = { + window: browser, + document: browser.document, + navigator: browser.navigator, + Node: browser.Node, + Element: browser.Element, + HTMLElement: browser.HTMLElement, + HTMLIFrameElement: browser.HTMLIFrameElement, + Event: browser.Event, + MouseEvent: browser.MouseEvent, + MutationObserver: browser.MutationObserver, + ResizeObserver: browser.ResizeObserver, + getComputedStyle: browser.getComputedStyle.bind(browser), + requestAnimationFrame: browser.requestAnimationFrame.bind(browser), + cancelAnimationFrame: browser.cancelAnimationFrame.bind(browser), + IS_REACT_ACT_ENVIRONMENT: true, + } as const; + const descriptors = new Map( + Object.keys(values).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + } + + return { + restore() { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + }, + }; +} + let MessagesTimeline: typeof import("./MessagesTimeline").MessagesTimeline; -let AgentMessageSourceTitle: typeof import("./MessagesTimeline").AgentMessageSourceTitle; beforeAll(async () => { const classList = { @@ -195,7 +237,7 @@ beforeAll(async () => { }, }); - ({ MessagesTimeline, AgentMessageSourceTitle } = await import("./MessagesTimeline")); + ({ MessagesTimeline } = await import("./MessagesTimeline")); }, 30_000); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -299,19 +341,56 @@ describe("MessagesTimeline", () => { expect(markup).toContain('download="handoff.pdf"'); }); - it("opens the source thread when its title is activated", () => { + it("navigates to the source thread when the rendered title is activated", async () => { const sourceThreadId = ThreadId.make("thread-source"); - const onOpenSourceThread = vi.fn(); - const sourceTitle = AgentMessageSourceTitle({ - sourceThreadId, - sourceTitle: "Mobile Reconnect Issue", - onOpenSourceThread, - }); + const entry = buildUserTimelineEntry("The reconnect fix is ready for review."); + const navigate = vi.fn(); + const browser = new Window({ url: "https://lastcode.test" }); + const globals = installBrowserGlobals(browser); + const container = browser.document.createElement("div"); + browser.document.body.append(container); + const root = createRoot(container as unknown as Element); + + try { + await act(() => { + root.render( + { + navigate( + buildThreadRouteLocation(scopeThreadRef(ACTIVE_THREAD_ENVIRONMENT_ID, threadId)), + ); + }} + timelineEntries={[ + { + ...entry, + message: { ...entry.message, sourceThreadId }, + }, + ]} + />, + ); + }); - expect(sourceTitle.type).toBe("button"); - sourceTitle.props.onClick?.(); - expect(onOpenSourceThread).toHaveBeenCalledOnce(); - expect(onOpenSourceThread).toHaveBeenCalledWith(sourceThreadId); + const sourceLink = container.querySelector( + 'button[aria-label="Open source thread: Mobile Reconnect Issue"]', + ); + expect(sourceLink).not.toBeNull(); + await act(() => + sourceLink?.dispatchEvent(new browser.MouseEvent("click", { bubbles: true })), + ); + expect(navigate).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { + environmentId: ACTIVE_THREAD_ENVIRONMENT_ID, + threadId: sourceThreadId, + }, + }); + } finally { + await act(() => root.unmount()); + globals.restore(); + await browser.close(); + } }); it("does not link to a source thread that is no longer available", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4a83966f856d..ae4ea9a3bd80 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1317,7 +1317,7 @@ function AgentMessageTimelineRow({ ); } -export function AgentMessageSourceTitle({ +function AgentMessageSourceTitle({ sourceThreadId, sourceTitle, onOpenSourceThread, diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index fd5bc39d836a..273588e05cbb 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -49,6 +49,16 @@ export function buildThreadRouteParams(ref: ScopedThreadRef): { }; } +export function buildThreadRouteLocation(ref: ScopedThreadRef): { + to: "/$environmentId/$threadId"; + params: ReturnType; +} { + return { + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(ref), + }; +} + export function buildDraftThreadRouteParams(draftId: DraftId): { draftId: DraftId; } { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdcca6f13e1f..6b4288325205 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: version: 7.0.0-dev.20260604.1 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -185,7 +185,7 @@ importers: version: 4.3.0 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -527,7 +527,7 @@ importers: version: link:../../packages/effect-codex-app-server vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -685,6 +685,9 @@ importers: compression: specifier: ^1.8.1 version: 1.8.1 + happy-dom: + specifier: ^20.12.0 + version: 20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) msw: specifier: 2.12.11 version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3) @@ -696,7 +699,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -751,7 +754,7 @@ importers: version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: @@ -770,7 +773,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -789,7 +792,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -802,7 +805,7 @@ importers: version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -824,7 +827,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -846,7 +849,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -880,7 +883,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/ssh: dependencies: @@ -905,7 +908,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -927,7 +930,7 @@ importers: version: 24.12.4 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -967,7 +970,7 @@ importers: version: 6.0.5 vite-plus: specifier: 'catalog:' - version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -4837,6 +4840,9 @@ packages: '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/whatwg-url@11.0.5': resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} @@ -5576,6 +5582,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + bufferutil@4.1.0: resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} @@ -6326,6 +6336,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -7001,6 +7015,10 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + happy-dom@20.12.0: + resolution: {integrity: sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g==} + engines: {node: '>=20.0.0'} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -10185,6 +10203,10 @@ packages: whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-url-minimum@0.1.2: resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} @@ -14624,6 +14646,8 @@ snapshots: '@types/webidl-conversions@7.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/whatwg-url@11.0.5': dependencies: '@types/webidl-conversions': 7.0.3 @@ -14702,7 +14726,7 @@ snapshots: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) transitivePeerDependencies: - bufferutil - msw @@ -14718,7 +14742,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil @@ -15476,6 +15500,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 24.12.4 + bufferutil@4.1.0: dependencies: node-gyp-build: 4.8.4 @@ -16129,6 +16157,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -17090,6 +17120,19 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@types/node': 24.12.4 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@3.0.0: {} has-flag@4.0.0: {} @@ -18876,7 +18919,7 @@ snapshots: outvariant@1.4.3: {} - oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 optionalDependencies: @@ -18899,7 +18942,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.57.0 '@oxfmt/binding-win32-ia32-msvc': 0.57.0 '@oxfmt/binding-win32-x64-msvc': 0.57.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@0.24.0: optionalDependencies: @@ -18910,7 +18953,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 0.24.0 '@oxlint-tsgolint/win32-x64': 0.24.0 - oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 '@oxlint/binding-android-arm64': 1.72.0 @@ -18932,7 +18975,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 oxlint-tsgolint: 0.24.0 - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -20733,7 +20776,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 @@ -20747,11 +20790,11 @@ snapshots: '@vitest/spy': 4.1.9 '@vitest/utils': 4.1.9 '@voidzero-dev/vite-plus-core': 0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxfmt: 0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -20795,7 +20838,7 @@ snapshots: optionalDependencies: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' - vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): + vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(happy-dom@20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) @@ -20820,6 +20863,7 @@ snapshots: optionalDependencies: '@types/node': 24.12.4 '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(bufferutil@4.1.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(utf-8-validate@6.0.6)(vitest@4.1.9) + happy-dom: 20.12.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - msw @@ -20950,6 +20994,8 @@ snapshots: whatwg-fetch@3.6.20: {} + whatwg-mimetype@3.0.0: {} + whatwg-url-minimum@0.1.2: {} whatwg-url@14.2.0: From ba3e1b64bb5a2d8d5940d71c3addbb2ca02469df Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Sun, 30 Aug 2026 14:46:59 -0700 Subject: [PATCH 5/5] fix(web): preserve agent message controls --- apps/web/src/components/chat/MessagesTimeline.test.tsx | 3 +++ apps/web/src/components/chat/MessagesTimeline.tsx | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index df844d83f83b..4c9a0432dcd1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -312,6 +312,7 @@ describe("MessagesTimeline", () => { const markup = renderToStaticMarkup( { expect(markup).toContain("AGENT MESSAGE"); expect(markup).toContain("Mobile Reconnect Issue"); expect(markup).toContain("Working"); + expect(markup).not.toContain("animate-status-pulse"); expect(markup).toContain('aria-label="Open source thread: Mobile Reconnect Issue"'); + expect(markup).toContain('aria-label="Revert to this message"'); expect(markup).toContain('download="handoff.pdf"'); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ae4ea9a3bd80..b04cac050606 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1255,7 +1255,7 @@ function AgentMessageTimelineRow({ const source = useThreadShell(scopeThreadRef(ctx.activeThreadEnvironmentId, sourceThreadId)); const compactStatus = useClientSettings((settings) => settings.compactLegacySidebarStatuses); - const status = source + const resolvedStatus = source ? (resolveThreadStatusPill({ thread: source }) ?? (source.session?.status === "error" ? { @@ -1271,6 +1271,8 @@ function AgentMessageTimelineRow({ pulse: false, })) : null; + const status = resolvedStatus ? { ...resolvedStatus, pulse: false } : null; + const canRevertAgentWork = typeof row.revertTurnCount === "number"; const sourceTitle = source?.title ?? "source thread"; const images = (row.message.attachments ?? []).filter(isImageAttachment); const files = (row.message.attachments ?? []).filter(isFileAttachment); @@ -1313,6 +1315,11 @@ function AgentMessageTimelineRow({ skills={ctx.skills} />
+ {canRevertAgentWork ? ( +
+ +
+ ) : null}
); }