| author | |
| committer | |
| log | 59d067bf9b4ea9cc94d4aeafeeba665cc3b06600 |
| tree | f1679a7463fb271d2e52d9dcb889dd0e641952ba |
| parent | 3bc314d088f34759f6ecac21eda3767529f5466c |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
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-sol9 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 | 1668 | ? "Compacting context" |
| 1669 | 1669 | : status === "completed" |
| 1670 | 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 | 3832 | }); |
| 3833 | 3833 | }); |
| 3834 | 3834 | |
| 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 | 3869 | const dispatchSteerIntoRun = (input: { |
| 3836 | 3870 | readonly command: Extract< |
| 3837 | 3871 | OrchestrationV2Command, |
| ... | ... | @@ -3897,14 +3931,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 3897 | 3931 | const targetMessage = input.projection.messages.find( |
| 3898 | 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 | 3939 | return yield* new OrchestratorDispatchError({ |
| 3902 | 3940 | commandId: input.command.commandId, |
| 3903 | 3941 | commandType: input.command.type, |
| 3904 | cause: | |
| 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.", | |
| 3942 | cause: "Wait for sign-out to finish before steering the thread.", | |
| 3908 | 3943 | }); |
| 3909 | 3944 | } |
| 3910 | 3945 | const rootNodeId = targetRun.rootNodeId; |
| ... | ... | @@ -4925,6 +4960,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 4925 | 4960 | payload: { ...sourcePlan, status: "completed" }, |
| 4926 | 4961 | }); |
| 4927 | 4962 | |
| 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 | 4970 | if (dispatchMode.type === "steer_active" || dispatchMode.type === "restart_active") { |
| 4929 | 4971 | yield* dispatchSteerIntoRun({ |
| 4930 | 4972 | command, |
| ... | ... | @@ -7695,6 +7737,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 7695 | 7737 | }); |
| 7696 | 7738 | } |
| 7697 | 7739 | |
| 7740 | if (yield* isRunCompacting(projection, command.targetRunId)) return; | |
| 7741 | ||
| 7698 | 7742 | const now = yield* DateTime.now; |
| 7699 | 7743 | const emitEvent = emit(events, command); |
| 7700 | 7744 | yield* emitEvent({ |
| ... | ... | @@ -11164,12 +11208,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 11164 | 11208 | |
| 11165 | 11209 | const plan = yield* dispatchOnce(command).pipe( |
| 11166 | 11210 | Effect.flatMap((planned) => |
| 11167 | // A settle that finds the provider already ended everything, or a | |
| 11168 | // stop that finds nothing running, has nothing to record. That is | |
| 11169 | // its expected outcome, not a failure. | |
| 11211 | // These commands can succeed without changing the current state. | |
| 11170 | 11212 | planned.events.length > 0 || |
| 11171 | 11213 | command.type === "thread.background-work.settle" || |
| 11172 | 11214 | command.type === "thread.stop" || |
| 11215 | command.type === "queued-message.promote-to-steer" || | |
| 11173 | 11216 | command.type === "subagent.stop" |
| 11174 | 11217 | ? Effect.succeed(planned) |
| 11175 | 11218 | : Effect.fail( |
| ... | ... | @@ -11221,8 +11264,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 11221 | 11264 | ); |
| 11222 | 11265 | |
| 11223 | 11266 | if (plan.events.length === 0) { |
| 11224 | // A settle that ended nothing still records its receipt: a replayed Stop | |
| 11225 | // effect then finds it instead of settling work that appeared since. | |
| 11267 | // A no-op consumes its command ID so a replay cannot change newer work. | |
| 11226 | 11268 | const resultSequence = yield* Effect.gen(function* () { |
| 11227 | 11269 | const sequence = yield* eventSink.latestSequence({ threadId: commandThreadId(command) }); |
| 11228 | 11270 | yield* commandReceipts.insertIfAbsent({ |
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+198-6| ... | ... | @@ -16,9 +16,11 @@ import { |
| 16 | 16 | type ChatAttachment, |
| 17 | 17 | CommandId, |
| 18 | 18 | EnvironmentId, |
| 19 | EventId, | |
| 19 | 20 | DEFAULT_SERVER_SETTINGS, |
| 20 | 21 | GitCommandError, |
| 21 | 22 | MessageId, |
| 23 | NodeId, | |
| 22 | 24 | ProjectId, |
| 23 | 25 | ProviderDriverKind, |
| 24 | 26 | ProviderInstanceId, |
| ... | ... | @@ -26,6 +28,7 @@ import { |
| 26 | 28 | ScheduledTaskId, |
| 27 | 29 | type ServerProvider, |
| 28 | 30 | ThreadId, |
| 31 | TurnItemId, | |
| 29 | 32 | } from "@t3tools/contracts"; |
| 30 | 33 | import * as Cause from "effect/Cause"; |
| 31 | 34 | import * as Deferred from "effect/Deferred"; |
| ... | ... | @@ -54,6 +57,7 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| 54 | 57 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 55 | 58 | import * as CommandReceiptStore from "./CommandReceiptStore.ts"; |
| 56 | 59 | import * as EffectOutbox from "./EffectOutbox.ts"; |
| 60 | import * as EventSink from "./EventSink.ts"; | |
| 57 | 61 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 58 | 62 | import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; |
| 59 | 63 | import * as ThreadLaunch from "./ThreadLaunchService.ts"; |
| ... | ... | @@ -248,6 +252,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 248 | 252 | layerTitleRegeneration, |
| 249 | 253 | layerOutbox, |
| 250 | 254 | layerDatabase, |
| 255 | layerOrchestrator, | |
| 251 | 256 | layerExternalServices, |
| 252 | 257 | ), |
| 253 | 258 | createWorktree, |
| ... | ... | @@ -737,12 +742,6 @@ it.effect("keeps native maintenance commands out of steering and restart message |
| 737 | 742 | next: "Continue with the parser", |
| 738 | 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 | 745 | ] as const) { |
| 747 | 746 | const launched = yield* launches.launch( |
| 748 | 747 | launchInput({ |
| ... | ... | @@ -781,6 +780,199 @@ it.effect("keeps native maintenance commands out of steering and restart message |
| 781 | 780 | }), |
| 782 | 781 | ); |
| 783 | 782 | |
| 783 | it.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 | ||
| 784 | 976 | it.effect("arms durable title generation after accepting the first message", () => |
| 785 | 977 | Effect.gen(function* () { |
| 786 | 978 | const harness = makeHarness({ |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+196-1| ... | ... | @@ -1093,7 +1093,7 @@ describe("deriveMessagesTimelineRows", () => { |
| 1093 | 1093 | ]); |
| 1094 | 1094 | }); |
| 1095 | 1095 | |
| 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 | 1097 | const fixture = makeStreamingTimelineFixture(); |
| 1098 | 1098 | const last = fixture.visibleTurnItems.at(-1)!; |
| 1099 | 1099 | if (last.item.type !== "assistant_message") throw new Error("Expected assistant fixture"); |
| ... | ... | @@ -1156,6 +1156,201 @@ describe("deriveMessagesTimelineRows", () => { |
| 1156 | 1156 | expect(computeStableMessagesTimelineRows(settledRows, stable).result).toEqual(settledRows); |
| 1157 | 1157 | }); |
| 1158 | 1158 | |
| 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 | 1354 | it("only enables assistant copy for the terminal assistant message in a turn", () => { |
| 1160 | 1355 | const rows = deriveMessagesTimelineRows({ |
| 1161 | 1356 | timelineEntries: [ |
apps/web/src/components/chat/MessagesTimeline.logic.ts+91-7| ... | ... | @@ -550,6 +550,7 @@ type MessagesTimelineRowContent = |
| 550 | 550 | kind: "working"; |
| 551 | 551 | id: string; |
| 552 | 552 | createdAt: string | null; |
| 553 | compaction?: WorkLogEntry; | |
| 553 | 554 | } |
| 554 | 555 | | { |
| 555 | 556 | kind: "thinking"; |
| ... | ... | @@ -581,6 +582,7 @@ type MessagesTimelineRowContent = |
| 581 | 582 | runId: RunId; |
| 582 | 583 | label: string; |
| 583 | 584 | expanded: boolean; |
| 585 | summaryOnly?: true; | |
| 584 | 586 | } |
| 585 | 587 | | { |
| 586 | 588 | kind: "attempt-fold"; |
| ... | ... | @@ -948,6 +950,34 @@ function responseBoundaryRunId(entry: TimelineEntry): RunId | null { |
| 948 | 950 | return null; |
| 949 | 951 | } |
| 950 | 952 | |
| 953 | function 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 | 982 | * A promptless provider restart replaces the native turn without adding a |
| 953 | 983 | * user message. Keep every provider turn since the initiating prompt in one |
| ... | ... | @@ -1030,6 +1060,7 @@ function deriveTurnFolds(input: { |
| 1030 | 1060 | /** Finished replies a steer superseded; they answer their prompt, so they stay out. */ |
| 1031 | 1061 | completeSupersededReplyEntryIds: ReadonlySet<string>; |
| 1032 | 1062 | }): ReadonlyMap<string, TurnFold> { |
| 1063 | const initialCompactions = initialCompactionEntryIds(input.timelineEntries); | |
| 1033 | 1064 | const interruptedRunIds = new Set<RunId>(); |
| 1034 | 1065 | const promptIndexByRunId = new Map<RunId, number>(); |
| 1035 | 1066 | for (const [index, entry] of input.timelineEntries.entries()) { |
| ... | ... | @@ -1143,6 +1174,13 @@ function deriveTurnFolds(input: { |
| 1143 | 1174 | } |
| 1144 | 1175 | const isCompaction = |
| 1145 | 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 | 1184 | const isFoldableTrailingActivity = |
| 1147 | 1185 | entry.kind === "work" && |
| 1148 | 1186 | entry.entry.toolLifecycleStatus !== "inProgress" && |
| ... | ... | @@ -1165,14 +1203,18 @@ function deriveTurnFolds(input: { |
| 1165 | 1203 | if (hiddenEntryIds.size === 0) { |
| 1166 | 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 | 1206 | const hidesNonCompactionWork = group.entries.some( |
| 1171 | 1207 | (entry) => |
| 1172 | 1208 | hiddenEntryIds.has(entry.id) && |
| 1173 | 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 | 1218 | continue; |
| 1177 | 1219 | } |
| 1178 | 1220 | |
| ... | ... | @@ -1186,8 +1228,19 @@ function deriveTurnFolds(input: { |
| 1186 | 1228 | input.latestRun?.runId === runId && input.latestRun.status === "interrupted"; |
| 1187 | 1229 | // A turn cut short by a steer leaves trailing work entries behind its |
| 1188 | 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 | 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 | 1244 | const elapsedMs = |
| 1192 | 1245 | input.latestRun?.runId === runId && input.latestRun.startedAt && input.latestRun.completedAt |
| 1193 | 1246 | ? computeElapsedMs(input.latestRun.startedAt, input.latestRun.completedAt) |
| ... | ... | @@ -1210,7 +1263,7 @@ function deriveTurnFolds(input: { |
| 1210 | 1263 | anchorEntryId: group.anchorEntryId, |
| 1211 | 1264 | createdAt: group.startBoundary ?? firstEntry.createdAt, |
| 1212 | 1265 | hiddenEntryIds, |
| 1213 | label, | |
| 1266 | label: startsCompacted ? `${label} (context compacted)` : label, | |
| 1214 | 1267 | }); |
| 1215 | 1268 | } |
| 1216 | 1269 | return foldsByAnchorEntryId; |
| ... | ... | @@ -1483,6 +1536,14 @@ export function deriveMessagesTimelineRows(input: { |
| 1483 | 1536 | input.isWorking && |
| 1484 | 1537 | entry.toolLifecycleStatus === "inProgress" && |
| 1485 | 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 | ); | |
| 1486 | 1547 | |
| 1487 | 1548 | // A steer continues the current turn. Keep its elapsed-time header below |
| 1488 | 1549 | // the initiating prompt (or automatic wake), rather than moving it down. |
| ... | ... | @@ -1572,6 +1633,9 @@ export function deriveMessagesTimelineRows(input: { |
| 1572 | 1633 | kind: "working", |
| 1573 | 1634 | id: "working-indicator-row", |
| 1574 | 1635 | createdAt: input.activeTurnStartedAt ?? null, |
| 1636 | ...(activeInitialCompaction?.kind === "work" | |
| 1637 | ? { compaction: activeInitialCompaction.entry } | |
| 1638 | : {}), | |
| 1575 | 1639 | }); |
| 1576 | 1640 | }; |
| 1577 | 1641 | let hasActivityRow = false; |
| ... | ... | @@ -1622,6 +1686,9 @@ export function deriveMessagesTimelineRows(input: { |
| 1622 | 1686 | runId: turnFold.runId, |
| 1623 | 1687 | label: turnFold.label, |
| 1624 | 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 | } |
| 1627 | 1694 | |
| ... | ... | @@ -1656,6 +1723,16 @@ export function deriveMessagesTimelineRows(input: { |
| 1656 | 1723 | ) { |
| 1657 | 1724 | const active = workEntryIsInActiveRun(timelineEntry.entry); |
| 1658 | 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 | 1736 | nextRows.push({ |
| 1660 | 1737 | kind: "context-compaction", |
| 1661 | 1738 | id: timelineEntry.id, |
| ... | ... | @@ -2248,7 +2325,9 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean |
| 2248 | 2325 | |
| 2249 | 2326 | switch (a.kind) { |
| 2250 | 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 | 2331 | case "thinking": { |
| 2253 | 2332 | const bt = b as typeof a; |
| 2254 | 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 | 2348 | |
| 2270 | 2349 | case "turn-fold": { |
| 2271 | 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 | } |
| 2274 | 2358 | |
| 2275 | 2359 | case "attempt-fold": { |
apps/web/src/components/chat/MessagesTimeline.test.tsx+61| ... | ... | @@ -492,6 +492,67 @@ function buildSnapShotTimelineEntry(previewUrl?: string) { |
| 492 | 492 | } |
| 493 | 493 | |
| 494 | 494 | describe("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 | 556 | it("shows dynamic tool input without cached output when the row is expanded", async () => { |
| 496 | 557 | activityTestState.expanded = true; |
| 497 | 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 | 1427 | ], |
| 1428 | 1428 | ); |
| 1429 | 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 | 1436 | const backgroundWorktreeSetup = |
| 1432 | 1437 | worktreeSetup !== null && |
| 1433 | 1438 | worktreeSetup.phase === "running" && |
| ... | ... | @@ -2789,16 +2794,22 @@ function TurnFoldTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "turn- |
| 2789 | 2794 | |
| 2790 | 2795 | return ( |
| 2791 | 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 | <button | |
| 2793 | type="button" | |
| 2794 | aria-expanded={row.expanded} | |
| 2795 | data-scroll-anchor-ignore | |
| 2796 | onClick={() => ctx.onToggleTurnFold(row.runId)} | |
| 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" | |
| 2798 | > | |
| 2799 | <span>{row.label}</span> | |
| 2800 | <MorphIcon className="size-3.5" icon={row.expanded ? ChevronDown : ChevronRight} /> | |
| 2801 | </button> | |
| 2797 | {row.summaryOnly ? ( | |
| 2798 | <span className="px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> | |
| 2799 | {row.label} | |
| 2800 | </span> | |
| 2801 | ) : ( | |
| 2802 | <button | |
| 2803 | type="button" | |
| 2804 | aria-expanded={row.expanded} | |
| 2805 | data-scroll-anchor-ignore | |
| 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 | 2813 | <TimelineRowTimestamp |
| 2803 | 2814 | createdAt={row.createdAt} |
| 2804 | 2815 | timestampFormat={ctx.timestampFormat} |
| ... | ... | @@ -3795,14 +3806,23 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 3795 | 3806 | use(TimelineRowActivityCtx); |
| 3796 | 3807 | // One span for every label so the setup-to-working handoff swaps text in |
| 3797 | 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 | 3818 | const label = isPreparingWorktree ? ( |
| 3800 | 3819 | "Setting up worktree…" |
| 3801 | ) : isCompacting ? ( | |
| 3802 | <CompactingLabel /> | |
| 3820 | ) : compacting || isCompacting ? ( | |
| 3821 | <CompactingLabel createdAt={compacting ? compactionStartedAt : undefined} /> | |
| 3803 | 3822 | ) : row.createdAt ? ( |
| 3804 | 3823 | <> |
| 3805 | 3824 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| 3825 | {compacted ? " (context compacted)" : null} | |
| 3806 | 3826 | </> |
| 3807 | 3827 | ) : ( |
| 3808 | 3828 | "Working..." |
| ... | ... | @@ -3811,10 +3831,20 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 3811 | 3831 | <div className="border-b border-border/60 pb-2 pt-1"> |
| 3812 | 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 | 3833 | <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 | 3844 | ref={shimmer ? observeVisibleAnimation : undefined} |
| 3815 | 3845 | className="relative shrink-0 overflow-hidden whitespace-nowrap" |
| 3816 | 3846 | > |
| 3817 | {label} | |
| 3847 | <span aria-hidden="true">{label}</span> | |
| 3818 | 3848 | {shimmer ? <ActivityShimmerOverlay>{label}</ActivityShimmerOverlay> : null} |
| 3819 | 3849 | </span> |
| 3820 | 3850 | {backgroundWorktreeSetup ? ( |
| ... | ... | @@ -3867,11 +3897,17 @@ function BackgroundWorktreeSetupChip({ snapshot }: { snapshot: WorktreeSetupSnap |
| 3867 | 3897 | ); |
| 3868 | 3898 | } |
| 3869 | 3899 | |
| 3870 | function CompactingLabel() { | |
| 3900 | function CompactingLabel({ createdAt }: { createdAt?: string | undefined }) { | |
| 3871 | 3901 | return ( |
| 3872 | 3902 | <span className="inline-flex items-center gap-1.5"> |
| 3873 | 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 | 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 | |
| 7 | 7 | import { |
| 8 | 8 | commandDetailRepeatsCommand, |
| 9 | contextCompactionLabel, | |
| 9 | 10 | extractCommandOutputText, |
| 10 | 11 | liveThoughtLine, |
| 11 | 12 | resolveViewedImageAsset, |
| ... | ... | @@ -22,6 +23,21 @@ import { |
| 22 | 23 | workEntryIndicatesToolSuccess, |
| 23 | 24 | } from "./presentation.js"; |
| 24 | 25 | |
| 26 | describe("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 | ||
| 25 | 41 | function commandItem( |
| 26 | 42 | fields: Partial<Extract<OrchestrationV2TurnItem, { type: "command_execution" }>> = {}, |
| 27 | 43 | ): OrchestrationV2TurnItem { |
packages/client-runtime/src/work-log/presentation.ts+7-1| ... | ... | @@ -55,7 +55,13 @@ export function contextCompactionLabel( |
| 55 | 55 | "status" | "beforeTokenCount" | "afterTokenCount" |
| 56 | 56 | >, |
| 57 | 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 | 65 | if (item.beforeTokenCount !== undefined && item.afterTokenCount !== undefined) { |
| 60 | 66 | return `Context compacted ${formatTokens(item.beforeTokenCount)} → ${formatTokens(item.afterTokenCount)} tokens`; |
| 61 | 67 | } |