authorclover caruso <git@paperclover.net> 2026-10-14 14:12:29-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log59d067bf9b4ea9cc94d4aeafeeba665cc3b06600
treef1679a7463fb271d2e52d9dcb889dd0e641952ba
parent3bc314d088f34759f6ecac21eda3767529f5466c
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

fix: summarize compaction and queue follow-ups until it finishes

Clover wants context compaction to use the turn status line and never reject a follow-up. Show Compacting for its own elapsed duration, then retain context compacted in live and finished summaries without another full-width divider. Keep mid-turn compactions and failed or stopped attempts visible. Queue steer and restart requests while manual or automatic compaction is pending or running. Preserve attachments, context, model options, and command idempotency; attempts to promote the queued message to steering leave it queued until compaction finishes. Apply this at the shared orchestrator so every client and provider uses the same durable behavior. Use recorded completion times for historical summaries, avoid empty disclosures for compaction-only work, and announce stable status labels without reading every timer tick. Assisted-by: gpt-6.1-sol

9 files changed, 675 insertions(+), 43 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+1-1
...@@ -1668,7 +1668,7 @@ describe("retained v2 feed presentation", () => {...@@ -1668,7 +1668,7 @@ describe("retained v2 feed presentation", () => {
1668 ? "Compacting context"1668 ? "Compacting context"
1669 : status === "completed"1669 : status === "completed"
1670 ? "Context compacted 899K → 19K tokens"1670 ? "Context compacted 899K → 19K tokens"
1671 : "Context compacted",1671 : "Context compaction stopped",
1672 },1672 },
1673 ],1673 ],
1674 });1674 });
apps/server/src/orchestration-v2/Orchestrator.ts+52-10
...@@ -3832,6 +3832,40 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -3832,6 +3832,40 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
3832 });3832 });
3833 });3833 });
38343834
3835 const isRunCompacting = (
3836 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "messages">,
3837 targetRunId: RunId,
3838 ) =>
3839 Effect.gen(function* () {
3840 const run = projection.runs.find((candidate) => candidate.id === targetRunId);
3841 if (run === undefined || !isBlockingRun(run)) return false;
3842
3843 const message = projection.messages.find((candidate) => candidate.id === run.userMessageId);
3844 if (
3845 message !== undefined &&
3846 message.attachments.length === 0 &&
3847 message.text.trim().toLowerCase() === "/compact"
3848 ) {
3849 return true;
3850 }
3851
3852 const records = yield* projectionStore
3853 .getThreadRecords(projection.thread.id, ["turnItems"], {
3854 turnItemTypes: ["compaction"],
3855 turnItemRunId: run.id,
3856 })
3857 .pipe(
3858 Effect.mapError(
3859 (cause) => new OrchestratorProjectionError({ threadId: projection.thread.id, cause }),
3860 ),
3861 );
3862 return records.turnItems.some(
3863 (item) =>
3864 item.nodeId === run.rootNodeId &&
3865 (item.status === "pending" || item.status === "running"),
3866 );
3867 });
3868
3835 const dispatchSteerIntoRun = (input: {3869 const dispatchSteerIntoRun = (input: {
3836 readonly command: Extract<3870 readonly command: Extract<
3837 OrchestrationV2Command,3871 OrchestrationV2Command,
...@@ -3897,14 +3931,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -3897,14 +3931,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
3897 const targetMessage = input.projection.messages.find(3931 const targetMessage = input.projection.messages.find(
3898 (message) => message.id === targetRun.userMessageId,3932 (message) => message.id === targetRun.userMessageId,
3899 );3933 );
3900 if (targetMessage !== undefined && isNativeMaintenanceCommand(targetMessage)) {3934 if (
3935 targetMessage !== undefined &&
3936 targetMessage.attachments.length === 0 &&
3937 targetMessage.text.trim().toLowerCase() === "/logout"
3938 ) {
3901 return yield* new OrchestratorDispatchError({3939 return yield* new OrchestratorDispatchError({
3902 commandId: input.command.commandId,3940 commandId: input.command.commandId,
3903 commandType: input.command.type,3941 commandType: input.command.type,
3904 cause:3942 cause: "Wait for sign-out to finish before steering the thread.",
3905 targetMessage.text.trim().toLowerCase() === "/compact"
3906 ? "Wait for context compaction to finish before steering the thread."
3907 : "Wait for sign-out to finish before steering the thread.",
3908 });3943 });
3909 }3944 }
3910 const rootNodeId = targetRun.rootNodeId;3945 const rootNodeId = targetRun.rootNodeId;
...@@ -4925,6 +4960,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -4925,6 +4960,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
4925 payload: { ...sourcePlan, status: "completed" },4960 payload: { ...sourcePlan, status: "completed" },
4926 });4961 });
49274962
4963 if (
4964 (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") &&
4965 (yield* isRunCompacting(projection, dispatchMode.targetRunId))
4966 ) {
4967 dispatchMode = { type: "queue_after_active" };
4968 }
4969
4928 if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") {4970 if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") {
4929 yield* dispatchSteerIntoRun({4971 yield* dispatchSteerIntoRun({
4930 command,4972 command,
...@@ -7695,6 +7737,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -7695,6 +7737,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
7695 });7737 });
7696 }7738 }
76977739
7740 if (yield* isRunCompacting(projection, command.targetRunId)) return;
7741
7698 const now = yield* DateTime.now;7742 const now = yield* DateTime.now;
7699 const emitEvent = emit(events, command);7743 const emitEvent = emit(events, command);
7700 yield* emitEvent({7744 yield* emitEvent({
...@@ -11164,12 +11208,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -11164,12 +11208,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1116411208
11165 const plan = yield* dispatchOnce(command).pipe(11209 const plan = yield* dispatchOnce(command).pipe(
11166 Effect.flatMap((planned) =>11210 Effect.flatMap((planned) =>
11167 // A settle that finds the provider already ended everything, or a11211 // These commands can succeed without changing the current state.
11168 // stop that finds nothing running, has nothing to record. That is
11169 // its expected outcome, not a failure.
11170 planned.events.length > 0 ||11212 planned.events.length > 0 ||
11171 command.type === "thread.background-work.settle" ||11213 command.type === "thread.background-work.settle" ||
11172 command.type === "thread.stop" ||11214 command.type === "thread.stop" ||
11215 command.type === "queued-message.promote-to-steer" ||
11173 command.type === "subagent.stop"11216 command.type === "subagent.stop"
11174 ? Effect.succeed(planned)11217 ? Effect.succeed(planned)
11175 : Effect.fail(11218 : Effect.fail(
...@@ -11221,8 +11264,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -11221,8 +11264,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
11221 );11264 );
1122211265
11223 if (plan.events.length === 0) {11266 if (plan.events.length === 0) {
11224 // A settle that ended nothing still records its receipt: a replayed Stop11267 // A no-op consumes its command ID so a replay cannot change newer work.
11225 // effect then finds it instead of settling work that appeared since.
11226 const resultSequence = yield* Effect.gen(function* () {11268 const resultSequence = yield* Effect.gen(function* () {
11227 const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) });11269 const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) });
11228 yield* commandReceipts.insertIfAbsent({11270 yield* commandReceipts.insertIfAbsent({
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+198-6
...@@ -16,9 +16,11 @@ import {...@@ -16,9 +16,11 @@ import {
16 type ChatAttachment,16 type ChatAttachment,
17 CommandId,17 CommandId,
18 EnvironmentId,18 EnvironmentId,
19 EventId,
19 DEFAULT_SERVER_SETTINGS,20 DEFAULT_SERVER_SETTINGS,
20 GitCommandError,21 GitCommandError,
21 MessageId,22 MessageId,
23 NodeId,
22 ProjectId,24 ProjectId,
23 ProviderDriverKind,25 ProviderDriverKind,
24 ProviderInstanceId,26 ProviderInstanceId,
...@@ -26,6 +28,7 @@ import {...@@ -26,6 +28,7 @@ import {
26 ScheduledTaskId,28 ScheduledTaskId,
27 type ServerProvider,29 type ServerProvider,
28 ThreadId,30 ThreadId,
31 TurnItemId,
29} from "@t3tools/contracts";32} from "@t3tools/contracts";
30import * as Cause from "effect/Cause";33import * as Cause from "effect/Cause";
31import * as Deferred from "effect/Deferred";34import * as Deferred from "effect/Deferred";
...@@ -54,6 +57,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts";...@@ -54,6 +57,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts";
54import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";57import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
55import * as CommandReceiptStore from "./CommandReceiptStore.ts";58import * as CommandReceiptStore from "./CommandReceiptStore.ts";
56import * as EffectOutbox from "./EffectOutbox.ts";59import * as EffectOutbox from "./EffectOutbox.ts";
60import * as EventSink from "./EventSink.ts";
57import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";61import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
58import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";62import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
59import * as ThreadLaunch from "./ThreadLaunchService.ts";63import * as ThreadLaunch from "./ThreadLaunchService.ts";
...@@ -248,6 +252,7 @@ function makeHarness(options: HarnessOptions = {}) {...@@ -248,6 +252,7 @@ function makeHarness(options: HarnessOptions = {}) {
248 layerTitleRegeneration,252 layerTitleRegeneration,
249 layerOutbox,253 layerOutbox,
250 layerDatabase,254 layerDatabase,
255 layerOrchestrator,
251 layerExternalServices,256 layerExternalServices,
252 ),257 ),
253 createWorktree,258 createWorktree,
...@@ -737,12 +742,6 @@ it.effect("keeps native maintenance commands out of steering and restart message...@@ -737,12 +742,6 @@ it.effect("keeps native maintenance commands out of steering and restart message
737 next: "Continue with the parser",742 next: "Continue with the parser",
738 mode: "steer_active",743 mode: "steer_active",
739 },744 },
740 {
741 name: "steer-compaction",
742 first: "/compact",
743 next: "Continue with the parser",
744 mode: "steer_active",
745 },
746 ] as const) {745 ] as const) {
747 const launched = yield* launches.launch(746 const launched = yield* launches.launch(
748 launchInput({747 launchInput({
...@@ -781,6 +780,199 @@ it.effect("keeps native maintenance commands out of steering and restart message...@@ -781,6 +780,199 @@ it.effect("keeps native maintenance commands out of steering and restart message
781 }),780 }),
782);781);
783782
783it.effect.each([
784 ...(["manual", "automatic", "pending"] as const).flatMap((compaction) =>
785 (["steer", "restart", "auto", "targeted-steer", "targeted-restart"] as const).map((intent) => ({
786 compaction,
787 intent,
788 })),
789 ),
790 { compaction: "completed", intent: "steer" },
791 { compaction: "child", intent: "steer" },
792] as const)(
793 "resolves $intent during $compaction compaction without losing the submission",
794 ({ compaction, intent }) =>
795 Effect.gen(function* () {
796 const harness = makeHarness();
797 yield* Effect.gen(function* () {
798 const launches = yield* ThreadLaunch.ThreadLaunchService;
799 const threads = yield* ThreadManagement.ThreadManagementService;
800 const sink = yield* EventSink.EventSinkV2;
801 const outbox = yield* EffectOutbox.EffectOutboxV2;
802 const launched = yield* launches.launch(
803 launchInput({
804 command: "compaction-queue:launch",
805 thread: "compaction-queue",
806 message: compaction === "manual" ? " /COMPACT " : "Fix the parser",
807 }),
808 );
809 const before = yield* threads.getThreadProjection(launched.threadId);
810 const active = before.runs[0]!;
811 const now = yield* DateTime.now;
812 yield* sink.write({
813 events: [
814 {
815 id: EventId.make("compaction-queue:running"),
816 type: "run.updated",
817 threadId: launched.threadId,
818 occurredAt: now,
819 payload: { ...active, status: "running", startedAt: now },
820 },
821 ...(compaction === "manual"
822 ? []
823 : [
824 {
825 id: EventId.make("compaction-queue:compacting"),
826 type: "turn-item.updated" as const,
827 threadId: launched.threadId,
828 occurredAt: now,
829 payload: {
830 id: TurnItemId.make("compaction-queue:item"),
831 type: "compaction" as const,
832 threadId: launched.threadId,
833 runId: active.id,
834 nodeId:
835 compaction === "child"
836 ? NodeId.make("compaction-child")
837 : active.rootNodeId,
838 providerThreadId: active.providerThreadId,
839 providerTurnId: null,
840 nativeItemRef: null,
841 parentItemId: null,
842 ordinal: 2,
843 status:
844 compaction === "completed"
845 ? ("completed" as const)
846 : compaction === "pending"
847 ? ("pending" as const)
848 : ("running" as const),
849 title: null,
850 driver: ProviderDriverKind.make("codex"),
851 startedAt: now,
852 completedAt: compaction === "completed" ? now : null,
853 updatedAt: now,
854 },
855 },
856 ]),
857 ],
858 });
859 const attachments: ChatAttachment[] = [
860 {
861 type: "image",
862 id: ChatAttachmentId.make("compaction-queue-image"),
863 name: "parser.png",
864 mimeType: "image/png",
865 sizeBytes: 42,
866 },
867 ];
868 const command = {
869 type: "message.dispatch" as const,
870 commandId: CommandId.make("compaction-queue:follow-up"),
871 threadId: launched.threadId,
872 messageId: MessageId.make("compaction-queue:follow-up"),
873 text: "Continue with the parser [image](t3-context://v1/file/parser)",
874 context: {
875 version: 1 as const,
876 records: [
877 {
878 version: 1 as const,
879 kind: "file" as const,
880 contextId: ComposerContextId.make("parser"),
881 label: "Parser screenshot",
882 attachmentId: attachments[0]!.id,
883 name: "parser.png",
884 mimeType: "image/png",
885 sizeBytes: 42,
886 },
887 ],
888 },
889 attachments,
890 modelSelection: { ...modelSelection, model: "gpt-5.2-codex" },
891 createdBy: "user" as const,
892 creationSource: "web" as const,
893 dispatchMode: {
894 type:
895 intent === "targeted-restart"
896 ? ("restart_active" as const)
897 : ("steer_active" as const),
898 targetRunId: active.id,
899 },
900 ...(intent === "targeted-steer" || intent === "targeted-restart"
901 ? {}
902 : { deliveryIntent: intent }),
903 };
904 if (compaction === "completed" || compaction === "child") {
905 const failure = yield* threads.dispatch(command).pipe(Effect.flip);
906 assert.include(String(failure.cause), "No running provider turn found");
907 assert.deepEqual(
908 (yield* threads.getThreadProjection(launched.threadId)).messages,
909 before.messages,
910 );
911 return;
912 }
913 const accepted = yield* threads.dispatch(command);
914 const retried = yield* threads.dispatch(command);
915 assert.equal(retried.sequence, accepted.sequence);
916 assert.deepEqual(retried.storedEvents, accepted.storedEvents);
917
918 const queued = yield* threads.getThreadProjection(launched.threadId);
919 assert.equal(queued.runs.length, 2);
920 assert.equal(queued.runs[0]?.status, "running");
921 const queuedRun = queued.runs[1]!;
922 assert.equal(queuedRun.status, "queued");
923 assert.deepEqual(queuedRun.modelSelection, command.modelSelection);
924 const message = queued.messages.find((candidate) => candidate.id === command.messageId)!;
925 assert.equal(message.text, command.text);
926 assert.deepEqual(message.attachments, command.attachments);
927 assert.deepEqual(message.context, command.context);
928 assert.equal(message.createdBy, command.createdBy);
929 assert.equal(message.creationSource, command.creationSource);
930 assert.equal(message.runId, queuedRun.id);
931 assert.isFalse(
932 (yield* outbox.listByCommandId(command.commandId)).some((effect) =>
933 effect.request.type.startsWith("provider-turn."),
934 ),
935 );
936
937 const promote = {
938 type: "queued-message.promote-to-steer" as const,
939 commandId: CommandId.make("compaction-queue:promote"),
940 threadId: launched.threadId,
941 queuedRunId: queuedRun.id,
942 targetRunId: active.id,
943 };
944 assert.deepEqual((yield* threads.dispatch(promote)).storedEvents, []);
945 assert.deepEqual((yield* threads.dispatch(promote)).storedEvents, []);
946 const afterPromotion = yield* threads.getThreadProjection(launched.threadId);
947 assert.deepEqual(afterPromotion.runs, queued.runs);
948 assert.deepEqual(afterPromotion.messages, queued.messages);
949
950 yield* sink.write({
951 events: [
952 {
953 id: EventId.make("compaction-queue:completed"),
954 type: "run.updated",
955 threadId: launched.threadId,
956 occurredAt: now,
957 payload: { ...active, status: "completed", completedAt: now },
958 },
959 ],
960 });
961 yield* threads.dispatch({
962 type: "queue.resume",
963 commandId: CommandId.make("compaction-queue:resume"),
964 threadId: launched.threadId,
965 });
966 const resumed = yield* threads.getThreadProjection(launched.threadId);
967 assert.equal(resumed.runs.find((run) => run.id === queuedRun.id)?.status, "starting");
968 assert.equal(
969 resumed.messages.filter((candidate) => candidate.id === command.messageId).length,
970 1,
971 );
972 }).pipe(Effect.provide(harness.layer));
973 }),
974);
975
784it.effect("arms durable title generation after accepting the first message", () =>976it.effect("arms durable title generation after accepting the first message", () =>
785 Effect.gen(function* () {977 Effect.gen(function* () {
786 const harness = makeHarness({978 const harness = makeHarness({
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+196-1
...@@ -1093,7 +1093,7 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1093,7 +1093,7 @@ describe("deriveMessagesTimelineRows", () => {
1093 ]);1093 ]);
1094 });1094 });
10951095
1096 it("gives live compaction the activity slot and restores Thinking when it completes", () => {1096 it("keeps mid-turn compaction in the activity slot and restores Thinking when it completes", () => {
1097 const fixture = makeStreamingTimelineFixture();1097 const fixture = makeStreamingTimelineFixture();
1098 const last = fixture.visibleTurnItems.at(-1)!;1098 const last = fixture.visibleTurnItems.at(-1)!;
1099 if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture");1099 if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
...@@ -1156,6 +1156,201 @@ describe("deriveMessagesTimelineRows", () => {...@@ -1156,6 +1156,201 @@ describe("deriveMessagesTimelineRows", () => {
1156 expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows);1156 expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows);
1157 });1157 });
11581158
1159 it("uses the initial compaction's own clock in the turn header, then remembers completion", () => {
1160 const fixture = makeStreamingTimelineFixture();
1161 const source = fixture.visibleTurnItems.at(-1)!;
1162 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1163 const compaction = {
1164 ...source,
1165 item: {
1166 ...source.item,
1167 type: "compaction" as const,
1168 driver: null,
1169 status: "running" as const,
1170 startedAt: DateTime.makeUnsafe(fixture.time(9)),
1171 completedAt: null,
1172 },
1173 };
1174 const entries = (status: "running" | "completed") =>
1175 deriveTimelineEntriesFromVisibleTurnItems({
1176 visibleTurnItems: [
1177 ...fixture.visibleTurnItems.slice(0, 4),
1178 { ...compaction, item: { ...compaction.item, status } },
1179 ],
1180 optimisticMessages: [],
1181 });
1182 const input = {
1183 isWorking: true,
1184 runningRunId: fixture.runId,
1185 activeTurnStartedAt: fixture.time(5),
1186 turnDiffSummaries: [],
1187 supportsConversationRollback: false,
1188 };
1189 const runningRows = deriveMessagesTimelineRows({
1190 ...input,
1191 timelineEntries: entries("running"),
1192 });
1193 expect(runningRows.find((row) => row.kind === "working")).toMatchObject({
1194 createdAt: fixture.time(5),
1195 compaction: { createdAt: fixture.time(9), toolLifecycleStatus: "inProgress" },
1196 });
1197 expect(runningRows.some((row) => row.kind === "context-compaction")).toBe(false);
1198 expect(runningRows.some((row) => row.kind === "thinking")).toBe(false);
1199
1200 const completedRows = deriveMessagesTimelineRows({
1201 ...input,
1202 timelineEntries: entries("completed"),
1203 });
1204 expect(completedRows.find((row) => row.kind === "working")).toMatchObject({
1205 createdAt: fixture.time(5),
1206 compaction: { toolLifecycleStatus: "completed" },
1207 });
1208 expect(completedRows.some((row) => row.kind === "context-compaction")).toBe(false);
1209 expect(completedRows.at(-1)?.kind).toBe("thinking");
1210 const stable = computeStableMessagesTimelineRows(runningRows, { result: [], byId: new Map() });
1211 expect(computeStableMessagesTimelineRows(completedRows, stable).result).toEqual(completedRows);
1212 });
1213
1214 it.each([false, true])(
1215 "keeps initial compaction in the completed turn summary when expanded=%s",
1216 (expanded) => {
1217 const fixture = makeStreamingTimelineFixture("Done");
1218 const source = fixture.visibleTurnItems.at(-1)!;
1219 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1220 const compaction = {
1221 ...source,
1222 sourceItemId: TurnItemId.make("initial-compaction"),
1223 item: {
1224 ...source.item,
1225 id: TurnItemId.make("initial-compaction"),
1226 type: "compaction" as const,
1227 driver: null,
1228 status: "completed" as const,
1229 startedAt: DateTime.makeUnsafe(fixture.time(6)),
1230 completedAt: DateTime.makeUnsafe(fixture.time(7)),
1231 },
1232 };
1233 const rows = deriveMessagesTimelineRows({
1234 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1235 visibleTurnItems: [
1236 ...fixture.visibleTurnItems.slice(0, 4),
1237 compaction,
1238 { ...source, item: { ...source.item, streaming: false, status: "completed" } },
1239 ],
1240 optimisticMessages: [],
1241 }),
1242 isWorking: false,
1243 latestRun: {
1244 runId: fixture.runId,
1245 status: "completed",
1246 startedAt: fixture.time(5),
1247 completedAt: fixture.time(27),
1248 },
1249 expandedRunIds: expanded ? new Set([fixture.runId]) : new Set(),
1250 turnDiffSummaries: [],
1251 supportsConversationRollback: false,
1252 });
1253 expect(
1254 rows.find((row) => row.kind === "turn-fold" && row.runId === fixture.runId),
1255 ).toMatchObject({
1256 label: "Worked for 22s (context compacted)",
1257 expanded,
1258 summaryOnly: true,
1259 });
1260 expect(rows.some((row) => row.kind === "context-compaction")).toBe(false);
1261 expect(rows.some((row) => row.kind === "message" && row.message.text === "Done")).toBe(true);
1262 },
1263 );
1264
1265 it.each(["latest run unavailable", "later run selected"] as const)(
1266 "uses recorded compaction completion for a historical summary when %s",
1267 (latest) => {
1268 const fixture = makeStreamingTimelineFixture();
1269 const source = fixture.visibleTurnItems.at(-1)!;
1270 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1271 const compaction = {
1272 ...source,
1273 item: {
1274 ...source.item,
1275 type: "compaction" as const,
1276 driver: null,
1277 runId: fixture.historyRunId,
1278 status: "completed" as const,
1279 startedAt: DateTime.makeUnsafe(fixture.time(1)),
1280 completedAt: DateTime.makeUnsafe(fixture.time(83)),
1281 updatedAt: DateTime.makeUnsafe(fixture.time(83)),
1282 },
1283 };
1284 const rows = deriveMessagesTimelineRows({
1285 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1286 visibleTurnItems: [fixture.visibleTurnItems[0]!, compaction],
1287 optimisticMessages: [],
1288 }),
1289 isWorking: false,
1290 latestRun:
1291 latest === "later run selected"
1292 ? {
1293 runId: fixture.runId,
1294 status: "completed",
1295 startedAt: fixture.time(100),
1296 completedAt: fixture.time(105),
1297 }
1298 : null,
1299 turnDiffSummaries: [],
1300 supportsConversationRollback: false,
1301 });
1302 expect(rows.find((row) => row.kind === "turn-fold")).toMatchObject({
1303 label: "Worked for 1m 23s (context compacted)",
1304 summaryOnly: true,
1305 });
1306 },
1307 );
1308
1309 it.each(
1310 (["failed", "cancelled", "interrupted"] as const).flatMap((status) =>
1311 [true, false].map((isWorking) => ({ status, isWorking })),
1312 ),
1313 )(
1314 "retains a visible initial compaction result when $status and working=$isWorking",
1315 ({ status, isWorking }) => {
1316 const fixture = makeStreamingTimelineFixture("Done");
1317 const source = fixture.visibleTurnItems.at(-1)!;
1318 if (source.item.type !== "assistant_message") throw new Error("Expected assistant fixture");
1319 const rows = deriveMessagesTimelineRows({
1320 timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
1321 visibleTurnItems: [
1322 ...fixture.visibleTurnItems.slice(0, 4),
1323 {
1324 ...source,
1325 sourceItemId: TurnItemId.make("failed-compaction"),
1326 item: {
1327 ...source.item,
1328 id: TurnItemId.make("failed-compaction"),
1329 type: "compaction",
1330 driver: null,
1331 status,
1332 },
1333 },
1334 fixture.visibleTurnItems[4]!,
1335 { ...source, item: { ...source.item, streaming: isWorking } },
1336 ],
1337 optimisticMessages: [],
1338 }),
1339 isWorking,
1340 runningRunId: isWorking ? fixture.runId : null,
1341 turnDiffSummaries: [],
1342 supportsConversationRollback: false,
1343 });
1344 expect(rows.find((row) => row.kind === "context-compaction")).toMatchObject({
1345 active: false,
1346 label: status === "failed" ? "Context compaction failed" : "Context compaction stopped",
1347 });
1348 if (isWorking) {
1349 expect(rows.find((row) => row.kind === "working")).not.toHaveProperty("compaction");
1350 }
1351 },
1352 );
1353
1159 it("only enables assistant copy for the terminal assistant message in a turn", () => {1354 it("only enables assistant copy for the terminal assistant message in a turn", () => {
1160 const rows = deriveMessagesTimelineRows({1355 const rows = deriveMessagesTimelineRows({
1161 timelineEntries: [1356 timelineEntries: [
apps/web/src/components/chat/MessagesTimeline.logic.ts+91-7
...@@ -550,6 +550,7 @@ type MessagesTimelineRowContent =...@@ -550,6 +550,7 @@ type MessagesTimelineRowContent =
550 kind: "working";550 kind: "working";
551 id: string;551 id: string;
552 createdAt: string | null;552 createdAt: string | null;
553 compaction?: WorkLogEntry;
553 }554 }
554 | {555 | {
555 kind: "thinking";556 kind: "thinking";
...@@ -581,6 +582,7 @@ type MessagesTimelineRowContent =...@@ -581,6 +582,7 @@ type MessagesTimelineRowContent =
581 runId: RunId;582 runId: RunId;
582 label: string;583 label: string;
583 expanded: boolean;584 expanded: boolean;
585 summaryOnly?: true;
584 }586 }
585 | {587 | {
586 kind: "attempt-fold";588 kind: "attempt-fold";
...@@ -948,6 +950,34 @@ function responseBoundaryRunId(entry: TimelineEntry): RunId | null {...@@ -948,6 +950,34 @@ function responseBoundaryRunId(entry: TimelineEntry): RunId | null {
948 return null;950 return null;
949}951}
950952
953function initialCompactionEntryIds(entries: ReadonlyArray<TimelineEntry>): ReadonlySet<string> {
954 const initial = new Set<string>();
955 const startedRuns = new Set<RunId>();
956 let runlessKey: RunId | null = null;
957
958 for (const entry of entries) {
959 if (timelineEntryStartsResponse(entry)) {
960 runlessKey = RunId.make(`runless:${entry.id}`);
961 continue;
962 }
963 const runId = timelineEntryFoldRunId(entry, runlessKey);
964 if (runId === null || startedRuns.has(runId)) continue;
965 if (
966 entry.kind === "message" &&
967 entry.message.text.trim() === "" &&
968 (entry.message.attachments?.length ?? 0) === 0
969 ) {
970 continue;
971 }
972 if (entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction") {
973 initial.add(entry.id);
974 } else {
975 startedRuns.add(runId);
976 }
977 }
978 return initial;
979}
980
951/**981/**
952 * A promptless provider restart replaces the native turn without adding a982 * A promptless provider restart replaces the native turn without adding a
953 * user message. Keep every provider turn since the initiating prompt in one983 * user message. Keep every provider turn since the initiating prompt in one
...@@ -1030,6 +1060,7 @@ function deriveTurnFolds(input: {...@@ -1030,6 +1060,7 @@ function deriveTurnFolds(input: {
1030 /** Finished replies a steer superseded; they answer their prompt, so they stay out. */1060 /** Finished replies a steer superseded; they answer their prompt, so they stay out. */
1031 completeSupersededReplyEntryIds: ReadonlySet<string>;1061 completeSupersededReplyEntryIds: ReadonlySet<string>;
1032}): ReadonlyMap<string, TurnFold> {1062}): ReadonlyMap<string, TurnFold> {
1063 const initialCompactions = initialCompactionEntryIds(input.timelineEntries);
1033 const interruptedRunIds = new Set<RunId>();1064 const interruptedRunIds = new Set<RunId>();
1034 const promptIndexByRunId = new Map<RunId, number>();1065 const promptIndexByRunId = new Map<RunId, number>();
1035 for (const [index, entry] of input.timelineEntries.entries()) {1066 for (const [index, entry] of input.timelineEntries.entries()) {
...@@ -1143,6 +1174,13 @@ function deriveTurnFolds(input: {...@@ -1143,6 +1174,13 @@ function deriveTurnFolds(input: {
1143 }1174 }
1144 const isCompaction =1175 const isCompaction =
1145 entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";1176 entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction";
1177 if (
1178 isCompaction &&
1179 (entry.entry.toolLifecycleStatus === "failed" ||
1180 entry.entry.toolLifecycleStatus === "stopped")
1181 ) {
1182 continue;
1183 }
1146 const isFoldableTrailingActivity =1184 const isFoldableTrailingActivity =
1147 entry.kind === "work" &&1185 entry.kind === "work" &&
1148 entry.entry.toolLifecycleStatus !== "inProgress" &&1186 entry.entry.toolLifecycleStatus !== "inProgress" &&
...@@ -1165,14 +1203,18 @@ function deriveTurnFolds(input: {...@@ -1165,14 +1203,18 @@ function deriveTurnFolds(input: {
1165 if (hiddenEntryIds.size === 0) {1203 if (hiddenEntryIds.size === 0) {
1166 continue;1204 continue;
1167 }1205 }
1168 // A lone compaction row stays visible on its own; it only folds away as
1169 // part of a turn that already folds other work.
1170 const hidesNonCompactionWork = group.entries.some(1206 const hidesNonCompactionWork = group.entries.some(
1171 (entry) =>1207 (entry) =>
1172 hiddenEntryIds.has(entry.id) &&1208 hiddenEntryIds.has(entry.id) &&
1173 !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"),1209 !(entry.kind === "work" && entry.entry.sourceActivityKind === "context-compaction"),
1174 );1210 );
1175 if (!hidesNonCompactionWork) {1211 const startsCompacted = group.entries.some(
1212 (entry) =>
1213 initialCompactions.has(entry.id) &&
1214 entry.kind === "work" &&
1215 entry.entry.toolLifecycleStatus === "completed",
1216 );
1217 if (!hidesNonCompactionWork && !startsCompacted) {
1176 continue;1218 continue;
1177 }1219 }
11781220
...@@ -1186,8 +1228,19 @@ function deriveTurnFolds(input: {...@@ -1186,8 +1228,19 @@ function deriveTurnFolds(input: {
1186 input.latestRun?.runId === runId && input.latestRun.status === "interrupted";1228 input.latestRun?.runId === runId && input.latestRun.status === "interrupted";
1187 // A turn cut short by a steer leaves trailing work entries behind its1229 // A turn cut short by a steer leaves trailing work entries behind its
1188 // terminal message — take whichever ended last.1230 // terminal message — take whichever ended last.
1231 const lastProjectedItem =
1232 lastEntry.kind === "work"
1233 ? lastEntry.entry.projectedItem
1234 : lastEntry.kind === "event"
1235 ? lastEntry.projectedItem
1236 : undefined;
1237 const lastItemCompletedAt = lastProjectedItem?.item.completedAt;
1189 const lastEntryEnd =1238 const lastEntryEnd =
1190 lastEntry.kind === "message" ? lastEntry.message.updatedAt : lastEntry.createdAt;1239 lastEntry.kind === "message"
1240 ? lastEntry.message.updatedAt
1241 : lastItemCompletedAt
1242 ? DateTime.formatIso(lastItemCompletedAt)
1243 : lastEntry.createdAt;
1191 const elapsedMs =1244 const elapsedMs =
1192 input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt1245 input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt
1193 ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)1246 ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt)
...@@ -1210,7 +1263,7 @@ function deriveTurnFolds(input: {...@@ -1210,7 +1263,7 @@ function deriveTurnFolds(input: {
1210 anchorEntryId: group.anchorEntryId,1263 anchorEntryId: group.anchorEntryId,
1211 createdAt: group.startBoundary ?? firstEntry.createdAt,1264 createdAt: group.startBoundary ?? firstEntry.createdAt,
1212 hiddenEntryIds,1265 hiddenEntryIds,
1213 label,1266 label: startsCompacted ? `${label} (context compacted)` : label,
1214 });1267 });
1215 }1268 }
1216 return foldsByAnchorEntryId;1269 return foldsByAnchorEntryId;
...@@ -1483,6 +1536,14 @@ export function deriveMessagesTimelineRows(input: {...@@ -1483,6 +1536,14 @@ export function deriveMessagesTimelineRows(input: {
1483 input.isWorking &&1536 input.isWorking &&
1484 entry.toolLifecycleStatus === "inProgress" &&1537 entry.toolLifecycleStatus === "inProgress" &&
1485 (entry.runId == null ? runlessWorkActive : entry.runId === unsettledRunId);1538 (entry.runId == null ? runlessWorkActive : entry.runId === unsettledRunId);
1539 const initialCompactions = initialCompactionEntryIds(timelineEntries);
1540 const activeInitialCompaction = timelineEntries.findLast(
1541 (entry) =>
1542 initialCompactions.has(entry.id) &&
1543 entry.kind === "work" &&
1544 runIdIsActiveResponse(entry.entry.runId) &&
1545 (entry.entry.toolLifecycleStatus === "completed" || workEntryIsInActiveRun(entry.entry)),
1546 );
14861547
1487 // A steer continues the current turn. Keep its elapsed-time header below1548 // A steer continues the current turn. Keep its elapsed-time header below
1488 // the initiating prompt (or automatic wake), rather than moving it down.1549 // the initiating prompt (or automatic wake), rather than moving it down.
...@@ -1572,6 +1633,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -1572,6 +1633,9 @@ export function deriveMessagesTimelineRows(input: {
1572 kind: "working",1633 kind: "working",
1573 id: "working-indicator-row",1634 id: "working-indicator-row",
1574 createdAt: input.activeTurnStartedAt ?? null,1635 createdAt: input.activeTurnStartedAt ?? null,
1636 ...(activeInitialCompaction?.kind === "work"
1637 ? { compaction: activeInitialCompaction.entry }
1638 : {}),
1575 });1639 });
1576 };1640 };
1577 let hasActivityRow = false;1641 let hasActivityRow = false;
...@@ -1622,6 +1686,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -1622,6 +1686,9 @@ export function deriveMessagesTimelineRows(input: {
1622 runId: turnFold.runId,1686 runId: turnFold.runId,
1623 label: turnFold.label,1687 label: turnFold.label,
1624 expanded: input.expandedRunIds?.has(turnFold.runId) ?? false,1688 expanded: input.expandedRunIds?.has(turnFold.runId) ?? false,
1689 ...([...turnFold.hiddenEntryIds].every((id) => initialCompactions.has(id))
1690 ? { summaryOnly: true as const }
1691 : {}),
1625 });1692 });
1626 }1693 }
16271694
...@@ -1656,6 +1723,16 @@ export function deriveMessagesTimelineRows(input: {...@@ -1656,6 +1723,16 @@ export function deriveMessagesTimelineRows(input: {
1656 ) {1723 ) {
1657 const active = workEntryIsInActiveRun(timelineEntry.entry);1724 const active = workEntryIsInActiveRun(timelineEntry.entry);
1658 hasActiveCompaction ||= active;1725 hasActiveCompaction ||= active;
1726 if (
1727 initialCompactions.has(timelineEntry.id) &&
1728 ((input.isWorking && runIdIsActiveResponse(timelineEntry.entry.runId)) ||
1729 [...foldsByAnchorEntryId.values()].some((fold) =>
1730 fold.hiddenEntryIds.has(timelineEntry.id),
1731 )) &&
1732 (active || timelineEntry.entry.toolLifecycleStatus === "completed")
1733 ) {
1734 continue;
1735 }
1659 nextRows.push({1736 nextRows.push({
1660 kind: "context-compaction",1737 kind: "context-compaction",
1661 id: timelineEntry.id,1738 id: timelineEntry.id,
...@@ -2248,7 +2325,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -2248,7 +2325,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
22482325
2249 switch (a.kind) {2326 switch (a.kind) {
2250 case "working":2327 case "working":
2251 return a.createdAt === (b as typeof a).createdAt;2328 return (
2329 a.createdAt === (b as typeof a).createdAt && a.compaction === (b as typeof a).compaction
2330 );
2252 case "thinking": {2331 case "thinking": {
2253 const bt = b as typeof a;2332 const bt = b as typeof a;
2254 return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded;2333 return a.createdAt === bt.createdAt && a.groupId === bt.groupId && a.expanded === bt.expanded;
...@@ -2269,7 +2348,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean...@@ -2269,7 +2348,12 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
22692348
2270 case "turn-fold": {2349 case "turn-fold": {
2271 const bf = b as typeof a;2350 const bf = b as typeof a;
2272 return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded;2351 return (
2352 a.createdAt === bf.createdAt &&
2353 a.label === bf.label &&
2354 a.expanded === bf.expanded &&
2355 a.summaryOnly === bf.summaryOnly
2356 );
2273 }2357 }
22742358
2275 case "attempt-fold": {2359 case "attempt-fold": {
apps/web/src/components/chat/MessagesTimeline.test.tsx+61
...@@ -492,6 +492,67 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {...@@ -492,6 +492,67 @@ function buildSnapShotTimelineEntry(previewUrl?: string) {
492}492}
493493
494describe("MessagesTimeline", () => {494describe("MessagesTimeline", () => {
495 it("times initial compaction in one status and transitions to working without a divider", async () => {
496 vi.unstubAllGlobals();
497 vi.useFakeTimers();
498 vi.setSystemTime(new Date("2026-01-01T00:01:22.000Z"));
499 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
500 vi.stubGlobal("requestAnimationFrame", () => 0);
501 vi.stubGlobal("cancelAnimationFrame", () => {});
502 const container = document.createElement("div");
503 document.body.append(container);
504 const root = createRoot(container);
505 const runId = RunId.make("run-1");
506 const render = (completed: boolean) =>
507 root.render(
508 <MessagesTimeline
509 {...buildProps()}
510 isWorking
511 isCompacting
512 runningRunId={runId}
513 activeTurnStartedAt="2025-12-31T23:59:40.000Z"
514 timelineEntries={[
515 {
516 id: "initial-compaction",
517 kind: "work",
518 createdAt: "2026-01-01T00:00:00.000Z",
519 entry: {
520 id: "initial-compaction",
521 runId,
522 createdAt: "2026-01-01T00:00:00.000Z",
523 tone: "info",
524 label: completed ? "Context compacted" : "Compacting context",
525 sourceActivityKind: "context-compaction",
526 toolLifecycleStatus: completed ? "completed" : "inProgress",
527 },
528 },
529 ]}
530 />,
531 );
532
533 try {
534 await act(() => render(false));
535 const status = container.querySelector('[role="status"]');
536 expect(status?.getAttribute("aria-label")).toBe("Compacting context");
537 expect(status?.textContent).toBe("Compacting for 1m 22s");
538 expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
539
540 await act(() => vi.advanceTimersByTime(1000));
541 expect(status?.textContent).toBe("Compacting for 1m 23s");
542 await act(() => render(true));
543 expect(container.querySelector('[role="status"]')).toBe(status);
544 expect(status?.getAttribute("aria-label")).toBe("Working (context compacted)");
545 expect(status?.textContent).toBe("Working for 1m 43s (context compacted)");
546 expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
547 expect(container.textContent).not.toContain("Compacting");
548 } finally {
549 await act(() => root.unmount());
550 container.remove();
551 vi.useRealTimers();
552 vi.unstubAllGlobals();
553 }
554 });
555
495 it("shows dynamic tool input without cached output when the row is expanded", async () => {556 it("shows dynamic tool input without cached output when the row is expanded", async () => {
496 activityTestState.expanded = true;557 activityTestState.expanded = true;
497 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);558 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
apps/web/src/components/chat/MessagesTimeline.tsx+53-17
...@@ -1427,7 +1427,12 @@ const ConversationTimeline = memo(function ConversationTimeline({...@@ -1427,7 +1427,12 @@ const ConversationTimeline = memo(function ConversationTimeline({
1427 ],1427 ],
1428 );1428 );
1429 const compactionAwaitingRow =1429 const compactionAwaitingRow =
1430 isCompacting && !rows.some((row) => row.kind === "context-compaction" && row.active);1430 isCompacting &&
1431 !rows.some(
1432 (row) =>
1433 (row.kind === "context-compaction" && row.active) ||
1434 (row.kind === "working" && row.compaction !== undefined),
1435 );
1431 const backgroundWorktreeSetup =1436 const backgroundWorktreeSetup =
1432 worktreeSetup !== null &&1437 worktreeSetup !== null &&
1433 worktreeSetup.phase === "running" &&1438 worktreeSetup.phase === "running" &&
...@@ -2789,16 +2794,22 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-...@@ -2789,16 +2794,22 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn-
27892794
2790 return (2795 return (
2791 <div className="group/timeline-row relative flex items-center gap-1 border-b border-border/60 pb-2 pe-0.5 pt-1">2796 <div className="group/timeline-row relative flex items-center gap-1 border-b border-border/60 pb-2 pe-0.5 pt-1">
2792 <button2797 {row.summaryOnly ? (
2793 type="button"2798 <span className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
2794 aria-expanded={row.expanded}2799 {row.label}
2795 data-scroll-anchor-ignore2800 </span>
2796 onClick={() => ctx.onToggleTurnFold(row.runId)}2801 ) : (
2797 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"2802 <button
2798 >2803 type="button"
2799 <span>{row.label}</span>2804 aria-expanded={row.expanded}
2800 <MorphIcon className="size-3.5" icon={row.expanded ? ChevronDown : ChevronRight} />2805 data-scroll-anchor-ignore
2801 </button>2806 onClick={() => ctx.onToggleTurnFold(row.runId)}
2807 className="flex cursor-pointer select-none items-center gap-1 rounded-md px-1 text-sm leading-relaxed text-muted-foreground tabular-nums transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
2808 >
2809 <span>{row.label}</span>
2810 <MorphIcon className="size-3.5" icon={row.expanded ? ChevronDown : ChevronRight} />
2811 </button>
2812 )}
2802 <TimelineRowTimestamp2813 <TimelineRowTimestamp
2803 createdAt={row.createdAt}2814 createdAt={row.createdAt}
2804 timestampFormat={ctx.timestampFormat}2815 timestampFormat={ctx.timestampFormat}
...@@ -3795,14 +3806,23 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -3795,14 +3806,23 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
3795 use(TimelineRowActivityCtx);3806 use(TimelineRowActivityCtx);
3796 // One span for every label so the setup-to-working handoff swaps text in3807 // One span for every label so the setup-to-working handoff swaps text in
3797 // place instead of remounting the row.3808 // place instead of remounting the row.
3798 const shimmer = isPreparingWorktree || isCompacting;3809 const compacting = row.compaction?.toolLifecycleStatus === "inProgress";
3810 const compacted = row.compaction?.toolLifecycleStatus === "completed";
3811 const projectedCompaction = row.compaction?.projectedItem?.item;
3812 const compactionStartedAt = projectedCompaction
3813 ? projectedCompaction.startedAt === null
3814 ? undefined
3815 : DateTime.formatIso(projectedCompaction.startedAt)
3816 : row.compaction?.createdAt;
3817 const shimmer = isPreparingWorktree || (isCompacting && !compacting);
3799 const label = isPreparingWorktree ? (3818 const label = isPreparingWorktree ? (
3800 "Setting up worktree…"3819 "Setting up worktree…"
3801 ) : isCompacting ? (3820 ) : compacting || isCompacting ? (
3802 <CompactingLabel />3821 <CompactingLabel createdAt={compacting ? compactionStartedAt : undefined} />
3803 ) : row.createdAt ? (3822 ) : row.createdAt ? (
3804 <>3823 <>
3805 Working for <WorkingTimer createdAt={row.createdAt} />3824 Working for <WorkingTimer createdAt={row.createdAt} />
3825 {compacted ? " (context compacted)" : null}
3806 </>3826 </>
3807 ) : (3827 ) : (
3808 "Working..."3828 "Working..."
...@@ -3811,10 +3831,20 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin...@@ -3811,10 +3831,20 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
3811 <div className="border-b border-border/60 pb-2 pt-1">3831 <div className="border-b border-border/60 pb-2 pt-1">
3812 <div className="flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">3832 <div className="flex h-6 min-w-0 items-baseline gap-2 px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
3813 <span3833 <span
3834 role="status"
3835 aria-label={
3836 isPreparingWorktree
3837 ? "Setting up worktree"
3838 : compacting || isCompacting
3839 ? "Compacting context"
3840 : compacted
3841 ? "Working (context compacted)"
3842 : "Working"
3843 }
3814 ref={shimmer ? observeVisibleAnimation : undefined}3844 ref={shimmer ? observeVisibleAnimation : undefined}
3815 className="relative shrink-0 overflow-hidden whitespace-nowrap"3845 className="relative shrink-0 overflow-hidden whitespace-nowrap"
3816 >3846 >
3817 {label}3847 <span aria-hidden="true">{label}</span>
3818 {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}3848 {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null}
3819 </span>3849 </span>
3820 {backgroundWorktreeSetup ? (3850 {backgroundWorktreeSetup ? (
...@@ -3867,11 +3897,17 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap...@@ -3867,11 +3897,17 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap
3867 );3897 );
3868}3898}
38693899
3870function CompactingLabel() {3900function CompactingLabel({ createdAt }: { createdAt?: string | undefined }) {
3871 return (3901 return (
3872 <span className="inline-flex items-center gap-1.5">3902 <span className="inline-flex items-center gap-1.5">
3873 <Minimize2Icon aria-hidden="true" className="size-3" />3903 <Minimize2Icon aria-hidden="true" className="size-3" />
3874 Compacting…3904 {createdAt ? (
3905 <span>
3906 Compacting for <WorkingTimer createdAt={createdAt} />
3907 </span>
3908 ) : (
3909 "Compacting…"
3910 )}
3875 </span>3911 </span>
3876 );3912 );
3877}3913}
packages/client-runtime/src/work-log/presentation.test.ts+16
...@@ -6,6 +6,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";...@@ -6,6 +6,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation";
66
7import {7import {
8 commandDetailRepeatsCommand,8 commandDetailRepeatsCommand,
9 contextCompactionLabel,
9 extractCommandOutputText,10 extractCommandOutputText,
10 liveThoughtLine,11 liveThoughtLine,
11 resolveViewedImageAsset,12 resolveViewedImageAsset,
...@@ -22,6 +23,21 @@ import {...@@ -22,6 +23,21 @@ import {
22 workEntryIndicatesToolSuccess,23 workEntryIndicatesToolSuccess,
23} from "./presentation.js";24} from "./presentation.js";
2425
26describe("contextCompactionLabel", () => {
27 it.each(["pending", "running", "waiting"] as const)("reports %s as active", (status) => {
28 expect(contextCompactionLabel({ status })).toBe("Compacting context");
29 });
30
31 it.each(["failed", "cancelled", "interrupted"] as const)(
32 "does not report %s compaction as successful even with token counts",
33 (status) => {
34 expect(contextCompactionLabel({ status, beforeTokenCount: 1000, afterTokenCount: 100 })).toBe(
35 status === "failed" ? "Context compaction failed" : "Context compaction stopped",
36 );
37 },
38 );
39});
40
25function commandItem(41function commandItem(
26 fields: Partial<Extract<OrchestrationV2TurnItem, { type: "command_execution" }>> = {},42 fields: Partial<Extract<OrchestrationV2TurnItem, { type: "command_execution" }>> = {},
27): OrchestrationV2TurnItem {43): OrchestrationV2TurnItem {
packages/client-runtime/src/work-log/presentation.ts+7-1
...@@ -55,7 +55,13 @@ export function contextCompactionLabel(...@@ -55,7 +55,13 @@ export function contextCompactionLabel(
55 "status" | "beforeTokenCount" | "afterTokenCount"55 "status" | "beforeTokenCount" | "afterTokenCount"
56 >,56 >,
57): string {57): string {
58 if (item.status === "running") return "Compacting context";58 if (item.status === "pending" || item.status === "running" || item.status === "waiting") {
59 return "Compacting context";
60 }
61 if (item.status === "failed") return "Context compaction failed";
62 if (item.status === "cancelled" || item.status === "interrupted") {
63 return "Context compaction stopped";
64 }
59 if (item.beforeTokenCount !== undefined && item.afterTokenCount !== undefined) {65 if (item.beforeTokenCount !== undefined && item.afterTokenCount !== undefined) {
60 return `Context compacted ${formatTokens(item.beforeTokenCount)} → ${formatTokens(item.afterTokenCount)} tokens`;66 return `Context compacted ${formatTokens(item.beforeTokenCount)} → ${formatTokens(item.afterTokenCount)} tokens`;
61 }67 }