authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-30 04:39:38-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 04:39:38-07:00
log5c9358acaa34f8619513bba1e6821f0fdec27f74
treef5cf4f697fcbf2f14fce0ff74cb576105d694197
parentcbe8052020aa0c02c63fbe090e7f1f25603622d1
signature Signed by PGP key B5690EEEBB952194

feat(web): regenerate thread titles from sidebar (#4810)


32 files changed, 1438 insertions(+), 24 deletions(-)

apps/server/src/environment/ServerEnvironment.test.ts+1
......@@ -69,6 +69,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
6969 expect(first.environmentId).toBe(second.environmentId);
7070 expect(second.capabilities.repositoryIdentity).toBe(true);
7171 expect(second.capabilities.connectionProbe).toBe(true);
72 expect(second.capabilities.threadTitleRegeneration).toBe(true);
7273 }),
7374 );
7475
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -142,6 +142,7 @@ export const make = Effect.gen(function* () {
142142 connectionProbe: true,
143143 threadSettlement: true,
144144 threadSnooze: true,
145 threadTitleRegeneration: true,
145146 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
146147 },
147148 };
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+28
......@@ -335,6 +335,14 @@ describe("OrchestrationEngine", () => {
335335 }),
336336 );
337337
338 await system.run(
339 engine.dispatch({
340 type: "thread.meta.update",
341 commandId: CommandId.make("cmd-thread-archive-title-regeneration"),
342 threadId: ThreadId.make("thread-archive"),
343 regenerateTitle: true,
344 }),
345 );
338346 await system.run(
339347 engine.dispatch({
340348 type: "thread.archive",
......@@ -346,6 +354,10 @@ describe("OrchestrationEngine", () => {
346354 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
347355 ?.archivedAt,
348356 ).not.toBeNull();
357 expect(
358 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
359 ?.titleRegeneration,
360 ).toBeNull();
349361
350362 await system.run(
351363 engine.dispatch({
......@@ -358,6 +370,22 @@ describe("OrchestrationEngine", () => {
358370 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
359371 ?.archivedAt,
360372 ).toBeNull();
373 expect(
374 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")
375 ?.titleRegeneration,
376 ).toBeNull();
377 await system.run(
378 engine.dispatch({
379 type: "thread.title.regeneration.complete",
380 commandId: CommandId.make("cmd-thread-archive-stale-title-completion"),
381 threadId: ThreadId.make("thread-archive"),
382 requestId: CommandId.make("cmd-thread-archive-title-regeneration"),
383 title: "Stale generated title",
384 }),
385 );
386 expect(
387 (await system.readModel()).threads.find((thread) => thread.id === "thread-archive")?.title,
388 ).toBe("Archive me");
361389
362390 await system.dispose();
363391 });
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+10
......@@ -611,6 +611,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
611611 settledAt: null,
612612 snoozedUntil: null,
613613 snoozedAt: null,
614 titleRegenerationRequestId: null,
615 titleRegenerationStartedAt: null,
614616 latestUserMessageAt: null,
615617 pendingApprovalCount: 0,
616618 pendingUserInputCount: 0,
......@@ -629,6 +631,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
629631 yield* projectionThreadRepository.upsert({
630632 ...existingRow.value,
631633 archivedAt: event.payload.archivedAt,
634 titleRegenerationRequestId: null,
635 titleRegenerationStartedAt: null,
632636 updatedAt: event.payload.updatedAt,
633637 });
634638 return;
......@@ -723,6 +727,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
723727 yield* projectionThreadRepository.upsert({
724728 ...existingRow.value,
725729 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
730 ...(event.payload.titleRegeneration !== undefined
731 ? {
732 titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,
733 titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null,
734 }
735 : {}),
726736 ...(event.payload.modelSelection !== undefined
727737 ? { modelSelection: event.payload.modelSelection }
728738 : {}),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -312,6 +312,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
312312 settledAt: null,
313313 snoozedUntil: null,
314314 snoozedAt: null,
315 titleRegeneration: null,
315316 deletedAt: null,
316317 messages: [
317318 {
......@@ -426,6 +427,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
426427 settledAt: null,
427428 snoozedUntil: null,
428429 snoozedAt: null,
430 titleRegeneration: null,
429431 session: {
430432 threadId: ThreadId.make("thread-1"),
431433 status: "running",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+23
......@@ -209,6 +209,15 @@ function mapLatestTurn(
209209 };
210210}
211211
212function mapTitleRegeneration(row: Schema.Schema.Type<typeof ProjectionThreadDbRowSchema>) {
213 return row.titleRegenerationRequestId != null && row.titleRegenerationStartedAt != null
214 ? {
215 requestId: row.titleRegenerationRequestId,
216 startedAt: row.titleRegenerationStartedAt,
217 }
218 : null;
219}
220
212221function mapSessionRow(
213222 row: Schema.Schema.Type<typeof ProjectionThreadSessionDbRowSchema>,
214223): OrchestrationSession {
......@@ -338,6 +347,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
338347 settled_at AS "settledAt",
339348 snoozed_until AS "snoozedUntil",
340349 snoozed_at AS "snoozedAt",
350 title_regeneration_request_id AS "titleRegenerationRequestId",
351 title_regeneration_started_at AS "titleRegenerationStartedAt",
341352 latest_user_message_at AS "latestUserMessageAt",
342353 pending_approval_count AS "pendingApprovalCount",
343354 pending_user_input_count AS "pendingUserInputCount",
......@@ -370,6 +381,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
370381 settled_at AS "settledAt",
371382 snoozed_until AS "snoozedUntil",
372383 snoozed_at AS "snoozedAt",
384 title_regeneration_request_id AS "titleRegenerationRequestId",
385 title_regeneration_started_at AS "titleRegenerationStartedAt",
373386 latest_user_message_at AS "latestUserMessageAt",
374387 pending_approval_count AS "pendingApprovalCount",
375388 pending_user_input_count AS "pendingUserInputCount",
......@@ -404,6 +417,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
404417 settled_at AS "settledAt",
405418 snoozed_until AS "snoozedUntil",
406419 snoozed_at AS "snoozedAt",
420 title_regeneration_request_id AS "titleRegenerationRequestId",
421 title_regeneration_started_at AS "titleRegenerationStartedAt",
407422 latest_user_message_at AS "latestUserMessageAt",
408423 pending_approval_count AS "pendingApprovalCount",
409424 pending_user_input_count AS "pendingUserInputCount",
......@@ -770,6 +785,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
770785 settled_at AS "settledAt",
771786 snoozed_until AS "snoozedUntil",
772787 snoozed_at AS "snoozedAt",
788 title_regeneration_request_id AS "titleRegenerationRequestId",
789 title_regeneration_started_at AS "titleRegenerationStartedAt",
773790 latest_user_message_at AS "latestUserMessageAt",
774791 pending_approval_count AS "pendingApprovalCount",
775792 pending_user_input_count AS "pendingUserInputCount",
......@@ -1206,6 +1223,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
12061223 settledAt: row.settledAt,
12071224 snoozedUntil: row.snoozedUntil,
12081225 snoozedAt: row.snoozedAt,
1226 titleRegeneration: mapTitleRegeneration(row),
12091227 deletedAt: row.deletedAt,
12101228 messages: messagesByThread.get(row.threadId) ?? [],
12111229 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
......@@ -1408,6 +1426,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
14081426 settledAt: row.settledAt,
14091427 snoozedUntil: row.snoozedUntil,
14101428 snoozedAt: row.snoozedAt,
1429 titleRegeneration: mapTitleRegeneration(row),
14111430 deletedAt: row.deletedAt,
14121431 messages: [],
14131432 proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
......@@ -1541,6 +1560,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
15411560 settledAt: row.settledAt,
15421561 snoozedUntil: row.snoozedUntil,
15431562 snoozedAt: row.snoozedAt,
1563 titleRegeneration: mapTitleRegeneration(row),
15441564 session: sessionByThread.get(row.threadId) ?? null,
15451565 latestUserMessageAt: row.latestUserMessageAt,
15461566 hasPendingApprovals: row.pendingApprovalCount > 0,
......@@ -1679,6 +1699,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
16791699 settledAt: row.settledAt,
16801700 snoozedUntil: row.snoozedUntil,
16811701 snoozedAt: row.snoozedAt,
1702 titleRegeneration: mapTitleRegeneration(row),
16821703 session: sessionByThread.get(row.threadId) ?? null,
16831704 latestUserMessageAt: row.latestUserMessageAt,
16841705 hasPendingApprovals: row.pendingApprovalCount > 0,
......@@ -1923,6 +1944,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
19231944 settledAt: threadRow.value.settledAt,
19241945 snoozedUntil: threadRow.value.snoozedUntil,
19251946 snoozedAt: threadRow.value.snoozedAt,
1947 titleRegeneration: mapTitleRegeneration(threadRow.value),
19261948 session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null,
19271949 latestUserMessageAt: threadRow.value.latestUserMessageAt,
19281950 hasPendingApprovals: threadRow.value.pendingApprovalCount > 0,
......@@ -2021,6 +2043,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20212043 settledAt: threadRow.value.settledAt,
20222044 snoozedUntil: threadRow.value.snoozedUntil,
20232045 snoozedAt: threadRow.value.snoozedAt,
2046 titleRegeneration: mapTitleRegeneration(threadRow.value),
20242047 deletedAt: null,
20252048 messages: messageRows.map((row) => {
20262049 const message = {
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+602-6
......@@ -146,6 +146,8 @@ describe("ProviderCommandReactor", () => {
146146 readonly threadModelSelection?: ModelSelection;
147147 readonly sessionModelSwitch?: "unsupported" | "in-session";
148148 readonly requiresNewThreadForModelChange?: boolean;
149 readonly titleRegenerationCompletionDispatchFailures?: number;
150 readonly titleRegenerationBeforeStart?: "one" | "two";
149151 readonly startSessionEffect?: (
150152 session: ProviderSession,
151153 ) => Effect.Effect<ProviderSession, ProviderAdapterRequestError>;
......@@ -353,8 +355,34 @@ describe("ProviderCommandReactor", () => {
353355 Layer.provide(RepositoryIdentityResolver.layer),
354356 Layer.provide(SqlitePersistenceMemory),
355357 );
358 let titleRegenerationCompletionDispatchAttempts = 0;
359 const reactorOrchestrationLayer = Layer.effect(
360 OrchestrationEngineService,
361 Effect.gen(function* () {
362 const engine = yield* OrchestrationEngineService;
363 return {
364 readEvents: engine.readEvents,
365 dispatch: (command) => {
366 if (command.type === "thread.title.regeneration.complete") {
367 titleRegenerationCompletionDispatchAttempts += 1;
368 if (
369 titleRegenerationCompletionDispatchAttempts <=
370 (input?.titleRegenerationCompletionDispatchFailures ?? 0)
371 ) {
372 return Effect.die(new Error("Injected title regeneration completion failure"));
373 }
374 }
375 return engine.dispatch(command);
376 },
377 get streamDomainEvents() {
378 return engine.streamDomainEvents;
379 },
380 latestSequence: engine.latestSequence,
381 } satisfies OrchestrationEngineService["Service"];
382 }),
383 ).pipe(Layer.provide(orchestrationLayer));
356384 const layer = ProviderCommandReactorLive.pipe(
357 Layer.provideMerge(orchestrationLayer),
385 Layer.provideMerge(reactorOrchestrationLayer),
358386 Layer.provideMerge(projectionSnapshotLayer),
359387 Layer.provideMerge(Layer.succeed(ProviderService, service)),
360388 Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)),
......@@ -387,9 +415,7 @@ describe("ProviderCommandReactor", () => {
387415 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
388416 const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery));
389417 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
390 scope = await Effect.runPromise(Scope.make("sequential"));
391 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
392 const drain = () => Effect.runPromise(reactor.drain);
418 const runEffect = <A, E>(effect: Effect.Effect<A, E>) => runtime!.runPromise(effect);
393419
394420 await Effect.runPromise(
395421 engine.dispatch({
......@@ -417,6 +443,45 @@ describe("ProviderCommandReactor", () => {
417443 createdAt: now,
418444 }),
419445 );
446 if (input?.titleRegenerationBeforeStart === "two") {
447 await Effect.runPromise(
448 engine.dispatch({
449 type: "thread.create",
450 commandId: CommandId.make("cmd-thread-create-2"),
451 threadId: ThreadId.make("thread-2"),
452 projectId: asProjectId("project-1"),
453 title: "Thread 2",
454 modelSelection: modelSelection,
455 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
456 runtimeMode: "approval-required",
457 branch: null,
458 worktreePath: null,
459 createdAt: now,
460 }),
461 );
462 }
463 const titleRegenerationThreadIds =
464 input?.titleRegenerationBeforeStart === "two"
465 ? [ThreadId.make("thread-1"), ThreadId.make("thread-2")]
466 : input?.titleRegenerationBeforeStart === "one"
467 ? [ThreadId.make("thread-1")]
468 : [];
469 for (const [index, threadId] of titleRegenerationThreadIds.entries()) {
470 await Effect.runPromise(
471 engine.dispatch({
472 type: "thread.meta.update",
473 commandId: CommandId.make(
474 `cmd-thread-title-regeneration-before-reactor-start-${index + 1}`,
475 ),
476 threadId,
477 regenerateTitle: true,
478 }),
479 );
480 }
481
482 scope = await Effect.runPromise(Scope.make("sequential"));
483 await Effect.runPromise(reactor.start().pipe(Scope.provide(scope)));
484 const drain = () => Effect.runPromise(reactor.drain);
420485
421486 return {
422487 engine,
......@@ -434,6 +499,10 @@ describe("ProviderCommandReactor", () => {
434499 runtimeSessions,
435500 stateDir,
436501 drain,
502 runEffect,
503 get titleRegenerationCompletionDispatchAttempts() {
504 return titleRegenerationCompletionDispatchAttempts;
505 },
437506 };
438507 }
439508
......@@ -637,6 +706,533 @@ describe("ProviderCommandReactor", () => {
637706 expect(thread?.title).toBe("Generated title");
638707 });
639708
709 it("regenerates a thread title from the current conversation", async () => {
710 const harness = await createHarness();
711 const now = "2026-01-01T00:00:00.000Z";
712 harness.generateThreadTitle.mockReturnValue(
713 Effect.succeed({ title: "Resolve stale reconnect state" }),
714 );
715
716 await harness.runEffect(
717 harness.engine.dispatch({
718 type: "thread.meta.update",
719 commandId: CommandId.make("cmd-thread-title-existing"),
720 threadId: ThreadId.make("thread-1"),
721 title: "Investigate reconnect regressions",
722 }),
723 );
724 await harness.runEffect(
725 harness.engine.dispatch({
726 type: "thread.turn.start",
727 commandId: CommandId.make("cmd-turn-start-before-title-regeneration"),
728 threadId: ThreadId.make("thread-1"),
729 message: {
730 messageId: asMessageId("user-message-before-title-regeneration"),
731 role: "user",
732 text: "Please investigate reconnect regressions after restarting the session.",
733 attachments: [],
734 },
735 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
736 runtimeMode: "approval-required",
737 createdAt: now,
738 }),
739 );
740 await harness.runEffect(
741 harness.engine.dispatch({
742 type: "thread.message.assistant.delta",
743 commandId: CommandId.make("cmd-assistant-before-title-regeneration"),
744 threadId: ThreadId.make("thread-1"),
745 messageId: asMessageId("assistant-message-before-title-regeneration"),
746 delta: "The remaining issue is stale reconnect state.",
747 createdAt: "2026-01-01T00:00:01.000Z",
748 }),
749 );
750 await harness.runEffect(
751 harness.engine.dispatch({
752 type: "thread.message.assistant.complete",
753 commandId: CommandId.make("cmd-assistant-complete-before-title-regeneration"),
754 threadId: ThreadId.make("thread-1"),
755 messageId: asMessageId("assistant-message-before-title-regeneration"),
756 createdAt: "2026-01-01T00:00:02.000Z",
757 }),
758 );
759 await harness.runEffect(
760 harness.engine.dispatch({
761 type: "thread.meta.update",
762 commandId: CommandId.make("cmd-thread-title-regenerate"),
763 threadId: ThreadId.make("thread-1"),
764 regenerateTitle: true,
765 }),
766 );
767
768 await harness.drain();
769
770 expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1);
771 expect(harness.generateThreadTitle.mock.calls[0]?.[0]).toMatchObject({
772 cwd: "/tmp/provider-project",
773 previousTitle: "Investigate reconnect regressions",
774 message: [
775 "USER:",
776 "Please investigate reconnect regressions after restarting the session.",
777 "",
778 "ASSISTANT:",
779 "The remaining issue is stale reconnect state.",
780 ].join("\n"),
781 });
782 const readModel = await harness.readModel();
783 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
784 expect(thread?.title).toBe("Resolve stale reconnect state");
785 expect(thread?.titleRegeneration).toBeNull();
786 });
787
788 it("clears title regeneration state left pending across reactor startup", async () => {
789 const harness = await createHarness({
790 titleRegenerationBeforeStart: "one",
791 });
792
793 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
794 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(1);
795 const readModel = await harness.readModel();
796 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
797 expect(thread?.title).toBe("Thread");
798 expect(thread?.titleRegeneration).toBeNull();
799 });
800
801 it("continues clearing startup title regeneration state after one completion fails", async () => {
802 const harness = await createHarness({
803 titleRegenerationBeforeStart: "two",
804 titleRegenerationCompletionDispatchFailures: 1,
805 });
806
807 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
808 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(2);
809 const readModel = await harness.readModel();
810 expect(
811 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.titleRegeneration,
812 ).not.toBeNull();
813 expect(
814 readModel.threads.find((entry) => entry.id === ThreadId.make("thread-2"))?.titleRegeneration,
815 ).toBeNull();
816 });
817
818 it("keeps the current title when regeneration returns the fallback", async () => {
819 const harness = await createHarness();
820 const now = "2026-01-01T00:00:00.000Z";
821 harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "New thread" }));
822
823 await harness.runEffect(
824 harness.engine.dispatch({
825 type: "thread.meta.update",
826 commandId: CommandId.make("cmd-thread-title-before-fallback-regeneration"),
827 threadId: ThreadId.make("thread-1"),
828 title: "Keep meaningful title",
829 }),
830 );
831 await harness.runEffect(
832 harness.engine.dispatch({
833 type: "thread.turn.start",
834 commandId: CommandId.make("cmd-turn-start-before-fallback-regeneration"),
835 threadId: ThreadId.make("thread-1"),
836 message: {
837 messageId: asMessageId("user-message-before-fallback-regeneration"),
838 role: "user",
839 text: "Investigate the reconnect state.",
840 attachments: [],
841 },
842 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
843 runtimeMode: "approval-required",
844 createdAt: now,
845 }),
846 );
847 await harness.runEffect(
848 harness.engine.dispatch({
849 type: "thread.meta.update",
850 commandId: CommandId.make("cmd-thread-title-fallback-regeneration"),
851 threadId: ThreadId.make("thread-1"),
852 regenerateTitle: true,
853 }),
854 );
855
856 await harness.drain();
857
858 const readModel = await harness.readModel();
859 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
860 expect(thread?.title).toBe("Keep meaningful title");
861 expect(thread?.titleRegeneration).toBeNull();
862 });
863
864 it("clears title regeneration state when generation fails", async () => {
865 const harness = await createHarness();
866 const now = "2026-01-01T00:00:00.000Z";
867
868 await harness.runEffect(
869 harness.engine.dispatch({
870 type: "thread.meta.update",
871 commandId: CommandId.make("cmd-thread-title-before-failed-regeneration"),
872 threadId: ThreadId.make("thread-1"),
873 title: "Keep title after failure",
874 }),
875 );
876 await harness.runEffect(
877 harness.engine.dispatch({
878 type: "thread.turn.start",
879 commandId: CommandId.make("cmd-turn-start-before-failed-regeneration"),
880 threadId: ThreadId.make("thread-1"),
881 message: {
882 messageId: asMessageId("user-message-before-failed-regeneration"),
883 role: "user",
884 text: "Investigate the reconnect state.",
885 attachments: [],
886 },
887 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
888 runtimeMode: "approval-required",
889 createdAt: now,
890 }),
891 );
892 await harness.runEffect(
893 harness.engine.dispatch({
894 type: "thread.meta.update",
895 commandId: CommandId.make("cmd-thread-title-failed-regeneration"),
896 threadId: ThreadId.make("thread-1"),
897 regenerateTitle: true,
898 }),
899 );
900
901 await harness.drain();
902
903 const readModel = await harness.readModel();
904 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
905 expect(thread?.title).toBe("Keep title after failure");
906 expect(thread?.titleRegeneration).toBeNull();
907 });
908
909 it("retries a failed completion and continues regenerating", async () => {
910 const harness = await createHarness({
911 titleRegenerationCompletionDispatchFailures: 1,
912 });
913 const now = "2026-01-01T00:00:00.000Z";
914 harness.generateThreadTitle
915 .mockReturnValueOnce(Effect.succeed({ title: "Title lost to completion failure" }))
916 .mockReturnValueOnce(Effect.succeed({ title: "Recovered regeneration worker" }));
917
918 await harness.runEffect(
919 harness.engine.dispatch({
920 type: "thread.meta.update",
921 commandId: CommandId.make("cmd-thread-title-before-completion-failure"),
922 threadId: ThreadId.make("thread-1"),
923 title: "Existing title",
924 }),
925 );
926 await harness.runEffect(
927 harness.engine.dispatch({
928 type: "thread.turn.start",
929 commandId: CommandId.make("cmd-turn-start-before-completion-failure"),
930 threadId: ThreadId.make("thread-1"),
931 message: {
932 messageId: asMessageId("user-message-before-completion-failure"),
933 role: "user",
934 text: "Investigate the reconnect state.",
935 attachments: [],
936 },
937 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
938 runtimeMode: "approval-required",
939 createdAt: now,
940 }),
941 );
942 await harness.runEffect(
943 harness.engine.dispatch({
944 type: "thread.meta.update",
945 commandId: CommandId.make("cmd-thread-title-regeneration-completion-failure"),
946 threadId: ThreadId.make("thread-1"),
947 regenerateTitle: true,
948 }),
949 );
950 await harness.drain();
951
952 let readModel = await harness.readModel();
953 let thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
954 expect(thread?.title).toBe("Title lost to completion failure");
955 expect(thread?.titleRegeneration).toBeNull();
956
957 await harness.runEffect(
958 harness.engine.dispatch({
959 type: "thread.meta.update",
960 commandId: CommandId.make("cmd-thread-title-regeneration-after-completion-failure"),
961 threadId: ThreadId.make("thread-1"),
962 regenerateTitle: true,
963 }),
964 );
965 await harness.drain();
966
967 expect(harness.generateThreadTitle).toHaveBeenCalledTimes(2);
968 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(3);
969 readModel = await harness.readModel();
970 thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
971 expect(thread?.title).toBe("Recovered regeneration worker");
972 expect(thread?.titleRegeneration).toBeNull();
973 });
974
975 it("keeps the full retained context and excludes attachments outside it", async () => {
976 const harness = await createHarness();
977 const now = "2026-01-01T00:00:00.000Z";
978 const retainedContext = "x".repeat(8_000);
979
980 await harness.runEffect(
981 harness.engine.dispatch({
982 type: "thread.meta.update",
983 commandId: CommandId.make("cmd-thread-title-before-truncated-regeneration"),
984 threadId: ThreadId.make("thread-1"),
985 title: "Existing title",
986 }),
987 );
988 await harness.runEffect(
989 harness.engine.dispatch({
990 type: "thread.turn.start",
991 commandId: CommandId.make("cmd-turn-start-before-truncated-regeneration"),
992 threadId: ThreadId.make("thread-1"),
993 message: {
994 messageId: asMessageId("user-message-before-truncated-regeneration"),
995 role: "user",
996 text: "Old visual issue",
997 attachments: [
998 {
999 type: "image",
1000 id: "old-title-context-image",
1001 name: "old-issue.png",
1002 mimeType: "image/png",
1003 sizeBytes: 5,
1004 },
1005 ],
1006 },
1007 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1008 runtimeMode: "approval-required",
1009 createdAt: now,
1010 }),
1011 );
1012 await harness.runEffect(
1013 harness.engine.dispatch({
1014 type: "thread.message.assistant.delta",
1015 commandId: CommandId.make("cmd-assistant-truncated-regeneration-context"),
1016 threadId: ThreadId.make("thread-1"),
1017 messageId: asMessageId("assistant-truncated-regeneration-context"),
1018 delta: `content before retained tail${"x".repeat(8_100)}`,
1019 createdAt: "2026-01-01T00:00:01.000Z",
1020 }),
1021 );
1022 await harness.runEffect(
1023 harness.engine.dispatch({
1024 type: "thread.message.assistant.complete",
1025 commandId: CommandId.make("cmd-assistant-truncated-regeneration-context-complete"),
1026 threadId: ThreadId.make("thread-1"),
1027 messageId: asMessageId("assistant-truncated-regeneration-context"),
1028 createdAt: "2026-01-01T00:00:02.000Z",
1029 }),
1030 );
1031 await harness.runEffect(
1032 harness.engine.dispatch({
1033 type: "thread.meta.update",
1034 commandId: CommandId.make("cmd-thread-title-regenerate-truncated-context"),
1035 threadId: ThreadId.make("thread-1"),
1036 regenerateTitle: true,
1037 }),
1038 );
1039
1040 await harness.drain();
1041
1042 expect(harness.generateThreadTitle.mock.calls[0]?.[0].message).toBe(
1043 `[Earlier content truncated]\n\n${retainedContext}`,
1044 );
1045 expect(harness.generateThreadTitle.mock.calls[0]?.[0].attachments).toBeUndefined();
1046 });
1047
1048 it("does not overwrite a manual rename while title regeneration is running", async () => {
1049 const harness = await createHarness();
1050 const now = "2026-01-01T00:00:00.000Z";
1051 const generatedTitle = await harness.runEffect(
1052 Deferred.make<{ readonly title: string }, never>(),
1053 );
1054 harness.generateThreadTitle.mockReturnValue(Deferred.await(generatedTitle));
1055
1056 await harness.runEffect(
1057 harness.engine.dispatch({
1058 type: "thread.meta.update",
1059 commandId: CommandId.make("cmd-thread-title-before-regeneration-race"),
1060 threadId: ThreadId.make("thread-1"),
1061 title: "Existing thread title",
1062 }),
1063 );
1064 await harness.runEffect(
1065 harness.engine.dispatch({
1066 type: "thread.turn.start",
1067 commandId: CommandId.make("cmd-turn-start-before-regeneration-race"),
1068 threadId: ThreadId.make("thread-1"),
1069 message: {
1070 messageId: asMessageId("user-message-before-regeneration-race"),
1071 role: "user",
1072 text: "Investigate the reconnect state.",
1073 attachments: [],
1074 },
1075 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1076 runtimeMode: "approval-required",
1077 createdAt: now,
1078 }),
1079 );
1080 await harness.runEffect(
1081 harness.engine.dispatch({
1082 type: "thread.meta.update",
1083 commandId: CommandId.make("cmd-thread-title-regeneration-race"),
1084 threadId: ThreadId.make("thread-1"),
1085 regenerateTitle: true,
1086 }),
1087 );
1088 await waitFor(() => harness.generateThreadTitle.mock.calls.length === 1);
1089 const pendingReadModel = await harness.readModel();
1090 expect(
1091 pendingReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))
1092 ?.titleRegeneration?.requestId,
1093 ).toBe(CommandId.make("cmd-thread-title-regeneration-race"));
1094
1095 await harness.runEffect(
1096 harness.engine.dispatch({
1097 type: "thread.meta.update",
1098 commandId: CommandId.make("cmd-thread-manual-rename-during-regeneration"),
1099 threadId: ThreadId.make("thread-1"),
1100 title: "Keep manual rename",
1101 }),
1102 );
1103 await harness.runEffect(
1104 Deferred.succeed(generatedTitle, { title: "Generated title should not win" }),
1105 );
1106 await harness.drain();
1107
1108 const readModel = await harness.readModel();
1109 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1110 expect(thread?.title).toBe("Keep manual rename");
1111 expect(thread?.titleRegeneration).toBeNull();
1112 });
1113
1114 it("does not overwrite a manual rename while title regeneration is queued", async () => {
1115 let releaseStart = () => {};
1116 const startGate = new Promise<void>((resolve) => {
1117 releaseStart = resolve;
1118 });
1119 const harness = await createHarness({
1120 startSessionEffect: (session) => Effect.promise(() => startGate).pipe(Effect.as(session)),
1121 });
1122 const now = "2026-01-01T00:00:00.000Z";
1123 harness.generateThreadTitle.mockReturnValue(
1124 Effect.succeed({ title: "Generated title should not win" }),
1125 );
1126
1127 await harness.runEffect(
1128 harness.engine.dispatch({
1129 type: "thread.meta.update",
1130 commandId: CommandId.make("cmd-thread-title-before-queued-regeneration"),
1131 threadId: ThreadId.make("thread-1"),
1132 title: "Existing thread title",
1133 }),
1134 );
1135 await harness.runEffect(
1136 harness.engine.dispatch({
1137 type: "thread.turn.start",
1138 commandId: CommandId.make("cmd-turn-start-before-queued-regeneration"),
1139 threadId: ThreadId.make("thread-1"),
1140 message: {
1141 messageId: asMessageId("user-message-before-queued-regeneration"),
1142 role: "user",
1143 text: "Investigate the reconnect state.",
1144 attachments: [],
1145 },
1146 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1147 runtimeMode: "approval-required",
1148 createdAt: now,
1149 }),
1150 );
1151 await waitFor(() => harness.startSession.mock.calls.length === 1);
1152
1153 await harness.runEffect(
1154 harness.engine.dispatch({
1155 type: "thread.meta.update",
1156 commandId: CommandId.make("cmd-thread-title-queued-regeneration"),
1157 threadId: ThreadId.make("thread-1"),
1158 regenerateTitle: true,
1159 }),
1160 );
1161 await harness.runEffect(
1162 harness.engine.dispatch({
1163 type: "thread.meta.update",
1164 commandId: CommandId.make("cmd-thread-manual-rename-before-regeneration-starts"),
1165 threadId: ThreadId.make("thread-1"),
1166 title: "Keep queued manual rename",
1167 }),
1168 );
1169 releaseStart();
1170 await harness.drain();
1171
1172 expect(harness.generateThreadTitle).not.toHaveBeenCalled();
1173 const readModel = await harness.readModel();
1174 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1175 expect(thread?.title).toBe("Keep queued manual rename");
1176 });
1177
1178 it("skips superseded title regeneration before generation starts", async () => {
1179 let releaseStart = () => {};
1180 const startGate = new Promise<void>((resolve) => {
1181 releaseStart = resolve;
1182 });
1183 const harness = await createHarness({
1184 startSessionEffect: (session) => Effect.promise(() => startGate).pipe(Effect.as(session)),
1185 });
1186 const now = "2026-01-01T00:00:00.000Z";
1187 harness.generateThreadTitle.mockReturnValue(
1188 Effect.succeed({ title: "Latest regenerated title" }),
1189 );
1190
1191 await harness.runEffect(
1192 harness.engine.dispatch({
1193 type: "thread.turn.start",
1194 commandId: CommandId.make("cmd-turn-start-before-superseded-regeneration"),
1195 threadId: ThreadId.make("thread-1"),
1196 message: {
1197 messageId: asMessageId("user-message-before-superseded-regeneration"),
1198 role: "user",
1199 text: "Investigate the reconnect state.",
1200 attachments: [],
1201 },
1202 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1203 runtimeMode: "approval-required",
1204 createdAt: now,
1205 }),
1206 );
1207 await waitFor(() => harness.startSession.mock.calls.length === 1);
1208
1209 await harness.runEffect(
1210 harness.engine.dispatch({
1211 type: "thread.meta.update",
1212 commandId: CommandId.make("cmd-thread-title-superseded-regeneration"),
1213 threadId: ThreadId.make("thread-1"),
1214 regenerateTitle: true,
1215 }),
1216 );
1217 await harness.runEffect(
1218 harness.engine.dispatch({
1219 type: "thread.meta.update",
1220 commandId: CommandId.make("cmd-thread-title-latest-regeneration"),
1221 threadId: ThreadId.make("thread-1"),
1222 regenerateTitle: true,
1223 }),
1224 );
1225 releaseStart();
1226 await harness.drain();
1227
1228 expect(harness.generateThreadTitle).toHaveBeenCalledTimes(1);
1229 expect(harness.titleRegenerationCompletionDispatchAttempts).toBe(1);
1230 const readModel = await harness.readModel();
1231 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
1232 expect(thread?.title).toBe("Latest regenerated title");
1233 expect(thread?.titleRegeneration).toBeNull();
1234 });
1235
6401236 it("does not overwrite an existing custom thread title on the first turn", async () => {
6411237 const harness = await createHarness();
6421238 const now = "2026-01-01T00:00:00.000Z";
......@@ -2178,7 +2774,7 @@ describe("ProviderCommandReactor", () => {
21782774 const harness = await createHarness();
21792775 const now = "2026-01-01T00:00:00.000Z";
21802776
2181 await Effect.runPromise(
2777 await harness.runEffect(
21822778 harness.engine.dispatch({
21832779 type: "thread.session.set",
21842780 commandId: CommandId.make("cmd-session-set-for-stop"),
......@@ -2197,7 +2793,7 @@ describe("ProviderCommandReactor", () => {
21972793 }),
21982794 );
21992795
2200 await Effect.runPromise(
2796 await harness.runEffect(
22012797 harness.engine.dispatch({
22022798 type: "thread.session.stop",
22032799 commandId: CommandId.make("cmd-session-stop"),
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+244-1
......@@ -51,6 +51,7 @@ type ProviderIntentEvent = Extract<
5151 OrchestrationEvent,
5252 {
5353 type:
54 | "thread.meta-updated"
5455 | "thread.runtime-mode-set"
5556 | "thread.turn-start-requested"
5657 | "thread.turn-interrupt-requested"
......@@ -90,6 +91,60 @@ const HANDLED_TURN_START_KEY_MAX = 10_000;
9091const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30);
9192const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
9293const DEFAULT_THREAD_TITLE = "New thread";
94const MAX_REGENERATION_ATTACHMENTS = 4;
95const MAX_THREAD_TITLE_CONTEXT_CHARS = 8_000;
96const THREAD_TITLE_CONTEXT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
97
98function formatThreadTitleContext(
99 messages: ReadonlyArray<{
100 readonly role: "user" | "assistant" | "system";
101 readonly text: string;
102 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
103 }>,
104): {
105 readonly message: string;
106 readonly attachments: ReadonlyArray<ChatAttachment>;
107} {
108 let context = "";
109 let truncated = false;
110 const retainedAttachments: Array<ChatAttachment> = [];
111
112 for (const message of messages.toReversed()) {
113 if (message.role === "system") {
114 continue;
115 }
116 const text = message.text.trim();
117 const attachmentSummary = (message.attachments ?? [])
118 .map((attachment) => attachment.name)
119 .join(", ");
120 const contents = [
121 ...(text.length > 0 ? [text] : []),
122 ...(attachmentSummary.length > 0 ? [`[Attachments: ${attachmentSummary}]`] : []),
123 ].join("\n");
124 if (contents.length === 0) {
125 continue;
126 }
127
128 const section = `${message.role.toUpperCase()}:\n${contents}`;
129 const separator = context.length > 0 ? "\n\n" : "";
130 const available = MAX_THREAD_TITLE_CONTEXT_CHARS - context.length - separator.length;
131 if (section.length > available) {
132 if (available > 0) {
133 context = `${section.slice(-available)}${separator}${context}`;
134 retainedAttachments.unshift(...(message.attachments ?? []));
135 }
136 truncated = true;
137 break;
138 }
139 context = `${section}${separator}${context}`;
140 retainedAttachments.unshift(...(message.attachments ?? []));
141 }
142
143 return {
144 message: truncated ? `${THREAD_TITLE_CONTEXT_TRUNCATION_MARKER}${context}` : context,
145 attachments: retainedAttachments.slice(-MAX_REGENERATION_ATTACHMENTS),
146 };
147}
93148
94149export function providerErrorLabel(value: string | undefined): string {
95150 const normalized = value?.trim();
......@@ -777,6 +832,170 @@ const make = Effect.gen(function* () {
777832 },
778833 );
779834
835 const regenerateThreadTitle = Effect.fn("regenerateThreadTitle")(function* (
836 event: Extract<ProviderIntentEvent, { type: "thread.meta-updated" }>,
837 requestId: CommandId,
838 ) {
839 if (event.payload.regenerateTitle !== true) {
840 return { _tag: "Superseded" } as const;
841 }
842
843 const thread = yield* resolveThread(event.payload.threadId);
844 if (!thread || thread.titleRegeneration?.requestId !== requestId) {
845 return { _tag: "Superseded" } as const;
846 }
847
848 const { message, attachments } = formatThreadTitleContext(thread.messages);
849 if (message.length === 0) {
850 return { _tag: "Completed", title: undefined } as const;
851 }
852
853 const previousTitle = event.payload.previousTitle ?? thread.title;
854 if (thread.title !== previousTitle) {
855 return { _tag: "Superseded" } as const;
856 }
857 const project = yield* resolveProject(thread.projectId);
858 const cwd =
859 resolveThreadWorkspaceCwd({
860 thread,
861 projects: project ? [project] : [],
862 }) ?? process.cwd();
863 const { textGenerationModelSelection: modelSelection } =
864 yield* serverSettingsService.getSettings;
865 const generated = yield* textGeneration.generateThreadTitle({
866 cwd,
867 message,
868 previousTitle,
869 ...(attachments.length > 0 ? { attachments } : {}),
870 modelSelection,
871 });
872 if (generated.title === DEFAULT_THREAD_TITLE || generated.title === previousTitle) {
873 return { _tag: "Completed", title: undefined } as const;
874 }
875
876 const latestThread = yield* resolveThread(event.payload.threadId);
877 if (
878 !latestThread ||
879 latestThread.titleRegeneration?.requestId !== requestId ||
880 latestThread.title !== previousTitle
881 ) {
882 return { _tag: "Superseded" } as const;
883 }
884
885 return { _tag: "Completed", title: generated.title } as const;
886 });
887 const dispatchThreadTitleRegenerationCompletion = Effect.fn(
888 "dispatchThreadTitleRegenerationCompletion",
889 )(function* (input: {
890 readonly threadId: ThreadId;
891 readonly requestId: CommandId;
892 readonly title?: string;
893 }) {
894 yield* orchestrationEngine.dispatch({
895 type: "thread.title.regeneration.complete",
896 commandId: yield* serverCommandId("thread-title-regeneration-complete"),
897 threadId: input.threadId,
898 requestId: input.requestId,
899 ...(input.title !== undefined ? { title: input.title } : {}),
900 });
901 });
902 const clearInterruptedThreadTitleRegenerations = Effect.fn(
903 "clearInterruptedThreadTitleRegenerations",
904 )(function* () {
905 const readModel = yield* projectionSnapshotQuery.getCommandReadModel();
906 yield* Effect.forEach(
907 readModel.threads,
908 (thread) => {
909 const requestId = thread.titleRegeneration?.requestId;
910 if (requestId === undefined) {
911 return Effect.void;
912 }
913 return dispatchThreadTitleRegenerationCompletion({
914 threadId: thread.id,
915 requestId,
916 }).pipe(
917 Effect.catchCause((cause) => {
918 if (Cause.hasInterruptsOnly(cause)) {
919 return Effect.interrupt;
920 }
921 return Effect.logWarning(
922 "provider command reactor failed to clear interrupted title regeneration",
923 {
924 threadId: thread.id,
925 cause: Cause.pretty(cause),
926 },
927 );
928 }),
929 );
930 },
931 { discard: true },
932 );
933 });
934 const processThreadTitleRegenerationSafely = Effect.fn("processThreadTitleRegenerationSafely")(
935 function* (event: Extract<ProviderIntentEvent, { type: "thread.meta-updated" }>) {
936 if (event.payload.regenerateTitle !== true) {
937 return;
938 }
939
940 const requestId = event.payload.titleRegeneration?.requestId ?? event.commandId;
941 if (requestId === null) {
942 return;
943 }
944 const result = yield* regenerateThreadTitle(event, requestId).pipe(
945 Effect.catchCause((cause) => {
946 if (Cause.hasInterruptsOnly(cause)) {
947 return Effect.failCause(cause);
948 }
949 return Effect.logWarning("provider command reactor failed to regenerate thread title", {
950 threadId: event.payload.threadId,
951 cause: Cause.pretty(cause),
952 }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const));
953 }),
954 );
955 if (result._tag === "Superseded") {
956 return;
957 }
958
959 const completion = {
960 threadId: event.payload.threadId,
961 requestId,
962 ...(result.title !== undefined ? { title: result.title } : {}),
963 };
964 yield* dispatchThreadTitleRegenerationCompletion(completion).pipe(
965 Effect.catchCause((cause) => {
966 if (Cause.hasInterruptsOnly(cause)) {
967 return Effect.failCause(cause);
968 }
969 return Effect.logWarning(
970 "provider command reactor retrying title regeneration completion",
971 {
972 threadId: event.payload.threadId,
973 cause: Cause.pretty(cause),
974 },
975 ).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion)));
976 }),
977 );
978 },
979 (effect, event) =>
980 effect.pipe(
981 Effect.catchCause((cause) => {
982 if (Cause.hasInterruptsOnly(cause)) {
983 return Effect.failCause(cause);
984 }
985 return Effect.logWarning(
986 "provider command reactor failed to complete title regeneration",
987 {
988 threadId: event.payload.threadId,
989 cause: Cause.pretty(cause),
990 },
991 );
992 }),
993 ),
994 );
995 const threadTitleRegenerationWorker = yield* makeDrainableWorker(
996 processThreadTitleRegenerationSafely,
997 );
998
780999 const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* (
7811000 event: Extract<ProviderIntentEvent, { type: "thread.turn-start-requested" }>,
7821001 ) {
......@@ -1047,6 +1266,9 @@ const make = Effect.gen(function* () {
10471266 eventType: event.type,
10481267 });
10491268 switch (event.type) {
1269 case "thread.meta-updated":
1270 yield* threadTitleRegenerationWorker.enqueue(event);
1271 return;
10501272 case "thread.runtime-mode-set": {
10511273 const thread = yield* resolveThread(event.payload.threadId);
10521274 if (!thread?.session || thread.session.status === "stopped") {
......@@ -1096,6 +1318,7 @@ const make = Effect.gen(function* () {
10961318 const start: ProviderCommandReactorShape["start"] = Effect.fn("start")(function* () {
10971319 const processEvent = Effect.fn("processEvent")(function* (event: OrchestrationEvent) {
10981320 if (
1321 (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) ||
10991322 event.type === "thread.runtime-mode-set" ||
11001323 event.type === "thread.turn-start-requested" ||
11011324 event.type === "thread.turn-interrupt-requested" ||
......@@ -1110,11 +1333,31 @@ const make = Effect.gen(function* () {
11101333 yield* Effect.forkScoped(
11111334 Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent),
11121335 );
1336
1337 // The domain event stream is hot, so work pending before this reactor
1338 // starts cannot be resumed. Correlated completions only clear the request
1339 // captured here, leaving any newer request untouched.
1340 yield* clearInterruptedThreadTitleRegenerations().pipe(
1341 Effect.catchCause((cause) => {
1342 if (Cause.hasInterruptsOnly(cause)) {
1343 return Effect.interrupt;
1344 }
1345 return Effect.logWarning(
1346 "provider command reactor failed to clear interrupted title regenerations",
1347 {
1348 cause: Cause.pretty(cause),
1349 },
1350 );
1351 }),
1352 );
11131353 });
11141354
11151355 return {
11161356 start,
1117 drain: worker.drain,
1357 drain: Effect.gen(function* () {
1358 yield* worker.drain;
1359 yield* threadTitleRegenerationWorker.drain;
1360 }),
11181361 } satisfies ProviderCommandReactorShape;
11191362});
11201363
apps/server/src/orchestration/decider.titleRegeneration.test.ts created+72
......@@ -0,0 +1,72 @@
1import {
2 CommandId,
3 ProjectId,
4 ProviderInstanceId,
5 ThreadId,
6 type OrchestrationReadModel,
7} from "@t3tools/contracts";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11
12import { decideOrchestrationCommand } from "./decider.ts";
13
14const UPDATED_AT = "2026-01-01T00:00:00.000Z";
15
16const readModel: OrchestrationReadModel = {
17 snapshotSequence: 0,
18 projects: [],
19 threads: [
20 {
21 id: ThreadId.make("thread-1"),
22 projectId: ProjectId.make("project-1"),
23 title: "Manual title",
24 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
25 runtimeMode: "full-access",
26 interactionMode: "default",
27 branch: null,
28 worktreePath: null,
29 latestTurn: null,
30 createdAt: UPDATED_AT,
31 updatedAt: UPDATED_AT,
32 archivedAt: null,
33 settledOverride: null,
34 settledAt: null,
35 snoozedUntil: null,
36 snoozedAt: null,
37 deletedAt: null,
38 messages: [],
39 proposedPlans: [],
40 activities: [],
41 checkpoints: [],
42 session: null,
43 },
44 ],
45 updatedAt: UPDATED_AT,
46};
47
48it.layer(NodeServices.layer)("title regeneration decider", (it) => {
49 it.effect("preserves updatedAt for a stale completion", () =>
50 Effect.gen(function* () {
51 const result = yield* decideOrchestrationCommand({
52 command: {
53 type: "thread.title.regeneration.complete",
54 commandId: CommandId.make("cmd-regeneration-complete"),
55 threadId: ThreadId.make("thread-1"),
56 requestId: CommandId.make("cmd-old-regeneration-request"),
57 title: "Generated title",
58 },
59 readModel,
60 });
61 const event = Array.isArray(result) ? result[0] : result;
62
63 expect(event.type).toBe("thread.meta-updated");
64 if (event.type === "thread.meta-updated") {
65 expect(event.payload).toEqual({
66 threadId: ThreadId.make("thread-1"),
67 updatedAt: UPDATED_AT,
68 });
69 }
70 }),
71 );
72});
apps/server/src/orchestration/decider.ts+38
......@@ -654,6 +654,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
654654 payload: {
655655 threadId: command.threadId,
656656 ...(command.title !== undefined ? { title: command.title } : {}),
657 ...(command.regenerateTitle === true
658 ? {
659 regenerateTitle: true as const,
660 previousTitle: thread.title,
661 titleRegeneration: {
662 requestId: command.commandId,
663 startedAt: occurredAt,
664 },
665 }
666 : {}),
667 ...(command.title !== undefined && thread.titleRegeneration != null
668 ? { titleRegeneration: null }
669 : {}),
657670 ...(command.modelSelection !== undefined
658671 ? { modelSelection: command.modelSelection }
659672 : {}),
......@@ -664,6 +677,31 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
664677 };
665678 }
666679
680 case "thread.title.regeneration.complete": {
681 const thread = yield* requireThread({
682 readModel,
683 command,
684 threadId: command.threadId,
685 });
686 const requestIsCurrent = thread.titleRegeneration?.requestId === command.requestId;
687 const occurredAt = yield* nowIso;
688 return {
689 ...(yield* withEventBase({
690 aggregateKind: "thread",
691 aggregateId: command.threadId,
692 occurredAt,
693 commandId: command.commandId,
694 })),
695 type: "thread.meta-updated",
696 payload: {
697 threadId: command.threadId,
698 ...(requestIsCurrent && command.title !== undefined ? { title: command.title } : {}),
699 ...(requestIsCurrent ? { titleRegeneration: null } : {}),
700 updatedAt: requestIsCurrent ? occurredAt : thread.updatedAt,
701 },
702 };
703 }
704
667705 case "thread.runtime-mode.set": {
668706 yield* requireThread({
669707 readModel,
apps/server/src/orchestration/projector.ts+4
......@@ -329,6 +329,7 @@ export function projectEvent(
329329 ...nextBase,
330330 threads: updateThread(nextBase.threads, payload.threadId, {
331331 archivedAt: payload.archivedAt,
332 titleRegeneration: null,
332333 updatedAt: payload.updatedAt,
333334 }),
334335 })),
......@@ -399,6 +400,9 @@ export function projectEvent(
399400 ...nextBase,
400401 threads: updateThread(nextBase.threads, payload.threadId, {
401402 ...(payload.title !== undefined ? { title: payload.title } : {}),
403 ...(payload.titleRegeneration !== undefined
404 ? { titleRegeneration: payload.titleRegeneration }
405 : {}),
402406 ...(payload.modelSelection !== undefined
403407 ? { modelSelection: payload.modelSelection }
404408 : {}),
apps/server/src/persistence/Layers/ProjectionThreads.ts+10
......@@ -47,6 +47,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
4747 settled_at,
4848 snoozed_until,
4949 snoozed_at,
50 title_regeneration_request_id,
51 title_regeneration_started_at,
5052 latest_user_message_at,
5153 pending_approval_count,
5254 pending_user_input_count,
......@@ -70,6 +72,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
7072 ${row.settledAt},
7173 ${row.snoozedUntil},
7274 ${row.snoozedAt},
75 ${row.titleRegenerationRequestId ?? null},
76 ${row.titleRegenerationStartedAt ?? null},
7377 ${row.latestUserMessageAt},
7478 ${row.pendingApprovalCount},
7579 ${row.pendingUserInputCount},
......@@ -93,6 +97,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
9397 settled_at = excluded.settled_at,
9498 snoozed_until = excluded.snoozed_until,
9599 snoozed_at = excluded.snoozed_at,
100 title_regeneration_request_id = excluded.title_regeneration_request_id,
101 title_regeneration_started_at = excluded.title_regeneration_started_at,
96102 latest_user_message_at = excluded.latest_user_message_at,
97103 pending_approval_count = excluded.pending_approval_count,
98104 pending_user_input_count = excluded.pending_user_input_count,
......@@ -123,6 +129,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
123129 settled_at AS "settledAt",
124130 snoozed_until AS "snoozedUntil",
125131 snoozed_at AS "snoozedAt",
132 title_regeneration_request_id AS "titleRegenerationRequestId",
133 title_regeneration_started_at AS "titleRegenerationStartedAt",
126134 latest_user_message_at AS "latestUserMessageAt",
127135 pending_approval_count AS "pendingApprovalCount",
128136 pending_user_input_count AS "pendingUserInputCount",
......@@ -155,6 +163,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
155163 settled_at AS "settledAt",
156164 snoozed_until AS "snoozedUntil",
157165 snoozed_at AS "snoozedAt",
166 title_regeneration_request_id AS "titleRegenerationRequestId",
167 title_regeneration_started_at AS "titleRegenerationStartedAt",
158168 latest_user_message_at AS "latestUserMessageAt",
159169 pending_approval_count AS "pendingApprovalCount",
160170 pending_user_input_count AS "pendingUserInputCount",
apps/server/src/persistence/Migrations.ts+2
......@@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts";
4747import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts";
4848import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts";
4949import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts";
50import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts";
5051
5152/**
5253 * Migration loader with all migrations defined inline.
......@@ -93,6 +94,7 @@ export const migrationEntries = [
9394 [32, "AuthPairingProofKeyThumbprint", Migration0032],
9495 [33, "ProjectionThreadsSettled", Migration0033],
9596 [34, "ProjectionThreadsSnoozed", Migration0034],
97 [35, "ProjectionThreadTitleRegeneration", Migration0035],
9698] as const;
9799
98100export const makeMigrationLoader = (throughId?: number) =>
apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts created+27
......@@ -0,0 +1,27 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("035_ProjectionThreadTitleRegeneration", (it) => {
12 it.effect("adds pending title regeneration columns", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 34 });
17 yield* runMigrations({ toMigrationInclusive: 35 });
18
19 const columns = yield* sql<{ readonly name: string }>`
20 PRAGMA table_info(projection_threads)
21 `;
22 const names = new Set(columns.map((column) => column.name));
23 assert.ok(names.has("title_regeneration_request_id"));
24 assert.ok(names.has("title_regeneration_started_at"));
25 }),
26 );
27});
apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.ts created+23
......@@ -0,0 +1,23 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_threads)
8 `;
9
10 if (!columns.some((column) => column.name === "title_regeneration_request_id")) {
11 yield* sql`
12 ALTER TABLE projection_threads
13 ADD COLUMN title_regeneration_request_id TEXT
14 `;
15 }
16
17 if (!columns.some((column) => column.name === "title_regeneration_started_at")) {
18 yield* sql`
19 ALTER TABLE projection_threads
20 ADD COLUMN title_regeneration_started_at TEXT
21 `;
22 }
23});
apps/server/src/persistence/Services/ProjectionThreads.ts+3
......@@ -7,6 +7,7 @@
77 * @module ProjectionThreadRepository
88 */
99import {
10 CommandId,
1011 IsoDateTime,
1112 ModelSelection,
1213 NonNegativeInt,
......@@ -40,6 +41,8 @@ export const ProjectionThread = Schema.Struct({
4041 settledAt: Schema.NullOr(IsoDateTime),
4142 snoozedUntil: Schema.NullOr(IsoDateTime),
4243 snoozedAt: Schema.NullOr(IsoDateTime),
44 titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)),
45 titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
4346 latestUserMessageAt: Schema.NullOr(IsoDateTime),
4447 pendingApprovalCount: NonNegativeInt,
4548 pendingUserInputCount: NonNegativeInt,
apps/server/src/textGeneration/ClaudeTextGeneration.ts+1
......@@ -342,6 +342,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu
342342 Effect.fn("ClaudeTextGeneration.generateThreadTitle")(function* (input) {
343343 const { prompt, outputSchema } = buildThreadTitlePrompt({
344344 message: input.message,
345 previousTitle: input.previousTitle,
345346 attachments: input.attachments,
346347 });
347348
apps/server/src/textGeneration/CodexTextGeneration.ts+1
......@@ -384,6 +384,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func
384384 );
385385 const { prompt, outputSchema } = buildThreadTitlePrompt({
386386 message: input.message,
387 previousTitle: input.previousTitle,
387388 attachments: input.attachments,
388389 });
389390
apps/server/src/textGeneration/CursorTextGeneration.ts+1
......@@ -242,6 +242,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu
242242 Effect.fn("CursorTextGeneration.generateThreadTitle")(function* (input) {
243243 const { prompt, outputSchema } = buildThreadTitlePrompt({
244244 message: input.message,
245 previousTitle: input.previousTitle,
245246 attachments: input.attachments,
246247 });
247248
apps/server/src/textGeneration/GrokTextGeneration.ts+1
......@@ -234,6 +234,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
234234 Effect.fn("GrokTextGeneration.generateThreadTitle")(function* (input) {
235235 const { prompt, outputSchema } = buildThreadTitlePrompt({
236236 message: input.message,
237 previousTitle: input.previousTitle,
237238 attachments: input.attachments,
238239 });
239240
apps/server/src/textGeneration/OpenCodeTextGeneration.ts+1
......@@ -598,6 +598,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration"
598598 Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) {
599599 const { prompt, outputSchema } = buildThreadTitlePrompt({
600600 message: input.message,
601 previousTitle: input.previousTitle,
601602 attachments: input.attachments,
602603 });
603604 const generated = yield* runOpenCodeJson({
apps/server/src/textGeneration/TextGeneration.ts+3-3
......@@ -62,6 +62,8 @@ export interface BranchNameGenerationResult {
6262export interface ThreadTitleGenerationInput {
6363 cwd: string;
6464 message: string;
65 /** Present when replacing an existing title from the current thread history. */
66 previousTitle?: string | undefined;
6567 attachments?: ReadonlyArray<ChatAttachment> | undefined;
6668 /** What model and provider to use for generation. */
6769 modelSelection: ModelSelection;
......@@ -107,9 +109,7 @@ export class TextGeneration extends Context.Service<
107109 input: BranchNameGenerationInput,
108110 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
109111
110 /**
111 * Generate a concise thread title from a user's first message.
112 */
112 /** Generate a concise thread title from a first message or thread history. */
113113 readonly generateThreadTitle: (
114114 input: ThreadTitleGenerationInput,
115115 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
apps/server/src/textGeneration/TextGenerationPrompts.test.ts+42
......@@ -175,6 +175,48 @@ describe("buildThreadTitlePrompt", () => {
175175 expect(result.prompt).toContain("image/png");
176176 expect(result.prompt).toContain("67890 bytes");
177177 });
178
179 it("regenerates from recent thread contents and identifies the previous title", () => {
180 const result = buildThreadTitlePrompt({
181 message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`,
182 previousTitle: "Investigate reconnect regressions",
183 });
184
185 expect(result.prompt).toContain(
186 "The user requested a new title based on the contents of this thread.",
187 );
188 expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".');
189 expect(result.prompt).toContain("better represents the current state of the thread");
190 expect(result.prompt).toContain(
191 "Capture the thread's intent, not a PR number or other superficial detail.",
192 );
193 expect(result.prompt).toContain("Thread contents:");
194 expect(result.prompt).toContain("The remaining issue is stale session state");
195 });
196
197 it("keeps the latest thread contents when regeneration context is truncated", () => {
198 const result = buildThreadTitlePrompt({
199 message: `${"old context ".repeat(1_000)}\n\nASSISTANT:\nCurrent thread state`,
200 previousTitle: "Old title",
201 });
202
203 expect(result.prompt).toContain("[Earlier content truncated]");
204 expect(result.prompt).toContain("Current thread state");
205 expect(result.prompt).not.toContain("[truncated]");
206 });
207
208 it("does not truncate an already-marked regeneration context twice", () => {
209 const retainedContext = "x".repeat(7_998);
210 const result = buildThreadTitlePrompt({
211 message: `[Earlier content truncated]\n\n${retainedContext}`,
212 previousTitle: "Old title",
213 });
214
215 expect(result.prompt).toContain(
216 `Thread contents:\n[Earlier content truncated]\n\n${retainedContext}`,
217 );
218 expect(result.prompt.match(/\[Earlier content truncated\]/g)).toHaveLength(1);
219 });
178220});
179221
180222describe("sanitizeThreadTitle", () => {
apps/server/src/textGeneration/TextGenerationPrompts.ts+44-4
......@@ -12,6 +12,8 @@ import type { ChatAttachment } from "@t3tools/contracts";
1212import { limitSection } from "./TextGenerationUtils.ts";
1313import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
1414
15const EARLIER_CONTENT_TRUNCATION_MARKER = "[Earlier content truncated]\n\n";
16
1517function policyInstruction(instruction: string | undefined): ReadonlyArray<string> {
1618 const trimmed = instruction?.trim();
1719 return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : [];
......@@ -150,10 +152,23 @@ interface PromptFromMessageInput {
150152 responseShape: string;
151153 rules: ReadonlyArray<string>;
152154 message: string;
155 messageLabel?: string | undefined;
156 preserveMessageEnd?: boolean | undefined;
153157 attachments?: ReadonlyArray<ChatAttachment> | undefined;
154158 additionalInstructions?: string | undefined;
155159}
156160
161function preserveMessageEnd(message: string): string {
162 const alreadyTruncated = message.startsWith(EARLIER_CONTENT_TRUNCATION_MARKER);
163 const contents = alreadyTruncated
164 ? message.slice(EARLIER_CONTENT_TRUNCATION_MARKER.length)
165 : message;
166 if (!alreadyTruncated && contents.length <= 8_000) {
167 return contents;
168 }
169 return `${EARLIER_CONTENT_TRUNCATION_MARKER}${contents.slice(-8_000)}`;
170}
171
157172function buildPromptFromMessage(input: PromptFromMessageInput): string {
158173 const attachmentLines = (input.attachments ?? []).map(
159174 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
......@@ -165,8 +180,10 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string {
165180 "Rules:",
166181 ...input.rules.map((rule) => `- ${rule}`),
167182 "",
168 "User message:",
169 limitSection(input.message, 8_000),
183 `${input.messageLabel ?? "User message"}:`,
184 input.preserveMessageEnd
185 ? preserveMessageEnd(input.message)
186 : limitSection(input.message, 8_000),
170187 ...policyInstruction(input.additionalInstructions),
171188 ];
172189 if (attachmentLines.length > 0) {
......@@ -207,21 +224,44 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) {
207224
208225export interface ThreadTitlePromptInput {
209226 message: string;
227 previousTitle?: string | undefined;
210228 attachments?: ReadonlyArray<ChatAttachment> | undefined;
211229 policy?: TextGenerationPolicy | undefined;
212230}
213231
214232export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
233 const isRegeneration = input.previousTitle !== undefined;
215234 const prompt = buildPromptFromMessage({
216 instruction: "You write concise thread titles for coding conversations.",
235 instruction: isRegeneration
236 ? [
237 "You write concise thread titles for coding conversations.",
238 "The user requested a new title based on the contents of this thread.",
239 `The previous title was ${JSON.stringify(input.previousTitle)}.`,
240 "Come up with a new title that better represents the current state of the thread.",
241 ].join("\n")
242 : "You write concise thread titles for coding conversations.",
217243 responseShape: "Return a JSON object with key: title.",
218244 rules: [
219 "Title should summarize the user's request, not restate it verbatim.",
245 isRegeneration
246 ? "Title should summarize the thread's current state, not just its initial request."
247 : "Title should summarize the user's request, not restate it verbatim.",
248 ...(isRegeneration
249 ? [
250 "Capture the thread's intent, not a PR number or other superficial detail.",
251 "Return a different title from the previous title.",
252 ]
253 : []),
220254 "Keep it short and specific (3-8 words).",
221255 "Avoid quotes, filler, prefixes, and trailing punctuation.",
222256 "If images are attached, use them as primary context for visual/UI issues.",
223257 ],
224258 message: input.message,
259 ...(isRegeneration
260 ? {
261 messageLabel: "Thread contents",
262 preserveMessageEnd: true,
263 }
264 : {}),
225265 attachments: input.attachments,
226266 additionalInstructions: input.policy?.threadTitleInstructions,
227267 });
apps/web/src/components/Sidebar.logic.test.ts+37
......@@ -1,6 +1,7 @@
11import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
22import {
33 archiveSelectedThreadEntries,
4 buildBulkTitleRegenerationContextMenuItem,
45 buildMultiSelectThreadContextMenuItems,
56 createThreadJumpHintVisibilityController,
67 getSidebarThreadIdsToPrewarm,
......@@ -149,6 +150,42 @@ describe("archiveSelectedThreadEntries", () => {
149150 });
150151});
151152
153describe("buildBulkTitleRegenerationContextMenuItem", () => {
154 it("counts only threads that can start a new regeneration", () => {
155 expect(
156 buildBulkTitleRegenerationContextMenuItem({
157 supportedCount: 4,
158 actionableCount: 3,
159 }),
160 ).toEqual({
161 id: "regenerate-title",
162 label: "Regenerate titles (3)",
163 });
164 });
165
166 it("shows a disabled progress item when every supported thread is pending", () => {
167 expect(
168 buildBulkTitleRegenerationContextMenuItem({
169 supportedCount: 2,
170 actionableCount: 0,
171 }),
172 ).toEqual({
173 id: "regenerate-title",
174 label: "Regenerating… (2)",
175 disabled: true,
176 });
177 });
178
179 it("omits the action when no selected environment supports it", () => {
180 expect(
181 buildBulkTitleRegenerationContextMenuItem({
182 supportedCount: 0,
183 actionableCount: 0,
184 }),
185 ).toBeNull();
186 });
187});
188
152189describe("buildMultiSelectThreadContextMenuItems", () => {
153190 it("offers bulk archive with the selected count", () => {
154191 expect(
apps/web/src/components/Sidebar.logic.ts+19
......@@ -18,6 +18,7 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
1818// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
1919// nearby thread usually reuses an already-hot subscription.
2020export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;
21
2122type SidebarProject = {
2223 id: string;
2324 title: string;
......@@ -94,6 +95,24 @@ export function buildMultiSelectThreadContextMenuItems(input: {
9495 ];
9596}
9697
98export function buildBulkTitleRegenerationContextMenuItem(input: {
99 supportedCount: number;
100 actionableCount: number;
101}): ContextMenuItem<"regenerate-title"> | null {
102 if (input.supportedCount === 0) return null;
103 if (input.actionableCount === 0) {
104 return {
105 id: "regenerate-title",
106 label: `Regenerating… (${input.supportedCount})`,
107 disabled: true,
108 };
109 }
110 return {
111 id: "regenerate-title",
112 label: `Regenerate titles (${input.actionableCount})`,
113 };
114}
115
97116export interface ThreadStatusPill {
98117 label:
99118 | "Working"
apps/web/src/components/SidebarV2.tsx+92-7
......@@ -106,6 +106,7 @@ import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"
106106import type { SidebarThreadSummary } from "../types";
107107import { cn } from "~/lib/utils";
108108import {
109 buildBulkTitleRegenerationContextMenuItem,
109110 formatWorkingDurationLabel,
110111 firstValidTimestampMs,
111112 hasUnseenCompletion,
......@@ -444,6 +445,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
444445 [thread.environmentId, thread.id],
445446 );
446447 const threadKey = scopedThreadKey(threadRef);
448 const isRegeneratingTitle = thread.titleRegeneration != null;
447449 const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]);
448450 const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey));
449451 const openPrLink = useOpenPrLink();
......@@ -721,7 +723,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
721723 ) : (
722724 <span
723725 className={cn(
724 "min-w-0 flex-1 text-sm",
726 "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
725727 shouldRecede ? "font-normal" : "font-medium",
726728 variant === "card"
727729 ? cn(
......@@ -742,6 +744,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
742744 ? "text-muted-foreground"
743745 : "text-muted-foreground/70",
744746 ),
747 isRegeneratingTitle && "opacity-[0.55]",
745748 )}
746749 >
747750 {thread.title}
......@@ -790,6 +793,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
790793 role="button"
791794 tabIndex={0}
792795 data-testid="sidebar-v2-row-slim"
796 aria-busy={isRegeneratingTitle || undefined}
793797 className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")}
794798 onClick={handleClick}
795799 onDoubleClick={handleDoubleClick}
......@@ -816,6 +820,11 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
816820 </span>
817821 {title}
818822 {terminalStatusIcon}
823 {isRegeneratingTitle ? (
824 <span role="status" className="sr-only">
825 Regenerating title
826 </span>
827 ) : null}
819828 {/* The PR badge stays outside the hover-fading slot: it must
820829 remain visible AND clickable while the row is hovered. Only
821830 the time/jump label yields to the settle affordance. */}
......@@ -900,6 +909,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
900909 role="button"
901910 tabIndex={0}
902911 data-testid="sidebar-v2-row-card"
912 aria-busy={isRegeneratingTitle || undefined}
903913 className={rowSurfaceClassName}
904914 onClick={handleClick}
905915 onDoubleClick={handleDoubleClick}
......@@ -998,7 +1008,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
9981008 ) : null}
9991009 </span>
10001010 </div>
1001 <div className="mt-1 flex min-w-0">{title}</div>
1011 <div className="mt-1 flex min-w-0">
1012 {title}
1013 {isRegeneratingTitle ? (
1014 <span role="status" className="sr-only">
1015 Regenerating title
1016 </span>
1017 ) : null}
1018 </div>
10021019 <div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground/75">
10031020 {thread.branch ? (
10041021 <span className="min-w-0 flex-1 truncate whitespace-nowrap">{thread.branch}</span>
......@@ -1912,16 +1929,28 @@ export default function SidebarV2() {
19121929 const count = threadKeys.length;
19131930 // Snooze (N) is offered when every selected thread can actually take
19141931 // it — a mixed selection with blocked-on-you work would half-apply.
1915 const selectionNow = new Date().toISOString();
1916 const snoozableThreads = threadKeys.flatMap((threadKey) => {
1932 const selectionNow = new Date();
1933 const selectedThreads = threadKeys.flatMap((threadKey) => {
19171934 const thread = threadByKeyRef.current.get(threadKey);
19181935 return thread ? [thread] : [];
19191936 });
1920 const canSnoozeSelection = snoozableThreads.every(
1937 const canSnoozeSelection = selectedThreads.every(
19211938 (thread) =>
19221939 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true &&
1923 canSnooze(thread, { now: selectionNow }),
1940 canSnooze(thread, { now: selectionNow.toISOString() }),
19241941 );
1942 const titleRegenerationThreads = selectedThreads.filter(
1943 (thread) =>
1944 serverConfigs.get(thread.environmentId)?.environment.capabilities
1945 .threadTitleRegeneration === true,
1946 );
1947 const regeneratableTitleThreads = titleRegenerationThreads.filter(
1948 (thread) => thread.titleRegeneration == null,
1949 );
1950 const titleRegenerationMenuItem = buildBulkTitleRegenerationContextMenuItem({
1951 supportedCount: titleRegenerationThreads.length,
1952 actionableCount: regeneratableTitleThreads.length,
1953 });
19251954 const snoozePresets = resolveSnoozePresets(new Date());
19261955 const clicked = await settlePromise(() =>
19271956 api.contextMenu.show(
......@@ -1939,6 +1968,7 @@ export default function SidebarV2() {
19391968 },
19401969 ]
19411970 : []),
1971 ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []),
19421972 { id: "mark-unread", label: `Mark unread (${count})` },
19431973 { id: "delete", label: `Delete (${count})`, destructive: true },
19441974 ],
......@@ -1954,7 +1984,7 @@ export default function SidebarV2() {
19541984 // Post-snooze navigation must skip threads snoozing in this same
19551985 // batch — they are all leaving the card block together.
19561986 const coSnoozingKeys = new Set(threadKeys);
1957 for (const thread of snoozableThreads) {
1987 for (const thread of selectedThreads) {
19581988 attemptSnooze(scopeThreadRef(thread.environmentId, thread.id), preset, {
19591989 coSnoozingKeys,
19601990 });
......@@ -1963,6 +1993,28 @@ export default function SidebarV2() {
19631993 }
19641994 return;
19651995 }
1996 if (clicked.value === "regenerate-title") {
1997 for (const thread of regeneratableTitleThreads) {
1998 const result = await updateThreadMetadata({
1999 environmentId: thread.environmentId,
2000 input: { threadId: thread.id, regenerateTitle: true },
2001 });
2002 if (result._tag === "Success") continue;
2003 if (!isAtomCommandInterrupted(result)) {
2004 const error = squashAtomCommandFailure(result);
2005 toastManager.add(
2006 stackedThreadToast({
2007 type: "error",
2008 title: "Failed to regenerate thread titles",
2009 description: error instanceof Error ? error.message : "An error occurred.",
2010 }),
2011 );
2012 }
2013 return;
2014 }
2015 clearSelection();
2016 return;
2017 }
19662018 if (clicked.value === "settle") {
19672019 // Post-settle navigation must skip threads settling in this same
19682020 // batch — they are all leaving the card block together. Rows that
......@@ -2034,6 +2086,7 @@ export default function SidebarV2() {
20342086 markThreadUnread,
20352087 removeFromSelection,
20362088 serverConfigs,
2089 updateThreadMetadata,
20372090 ],
20382091 );
20392092
......@@ -2063,6 +2116,10 @@ export default function SidebarV2() {
20632116 true;
20642117 const supportsSnooze =
20652118 serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true;
2119 const supportsTitleRegeneration =
2120 serverConfigs.get(thread.environmentId)?.environment.capabilities
2121 .threadTitleRegeneration === true;
2122 const isRegeneratingTitle = thread.titleRegeneration != null;
20662123 const isSettled = settledThreadKeysRef.current.has(threadKey);
20672124 const isSnoozed = snoozedThreadKeysRef.current.has(threadKey);
20682125 // Presets resolve at menu-open time (same as the popover).
......@@ -2101,6 +2158,15 @@ export default function SidebarV2() {
21012158 ]
21022159 : []),
21032160 { id: "rename", label: "Rename thread" },
2161 ...(supportsTitleRegeneration
2162 ? [
2163 {
2164 id: "regenerate-title",
2165 label: isRegeneratingTitle ? "Regenerating…" : "Regenerate title",
2166 disabled: isRegeneratingTitle,
2167 },
2168 ]
2169 : []),
21042170 { id: "mark-unread", label: "Mark unread" },
21052171 { id: "copy-path", label: "Copy path", icon: "copy" },
21062172 ...(thread.branch ? [{ id: "copy-branch", label: "Copy branch", icon: "copy" }] : []),
......@@ -2153,6 +2219,24 @@ export default function SidebarV2() {
21532219 case "rename":
21542220 startThreadRename(threadRef, thread.title);
21552221 return;
2222 case "regenerate-title": {
2223 if (isRegeneratingTitle) return;
2224 const result = await updateThreadMetadata({
2225 environmentId: threadRef.environmentId,
2226 input: { threadId: threadRef.threadId, regenerateTitle: true },
2227 });
2228 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
2229 const error = squashAtomCommandFailure(result);
2230 toastManager.add(
2231 stackedThreadToast({
2232 type: "error",
2233 title: "Failed to regenerate thread title",
2234 description: error instanceof Error ? error.message : "An error occurred.",
2235 }),
2236 );
2237 }
2238 return;
2239 }
21562240 case "mark-unread":
21572241 markThreadUnread(threadKey, thread.latestTurn?.completedAt);
21582242 return;
......@@ -2219,6 +2303,7 @@ export default function SidebarV2() {
22192303 projectCwdByKey,
22202304 serverConfigs,
22212305 startThreadRename,
2306 updateThreadMetadata,
22222307 ],
22232308 );
22242309
packages/client-runtime/src/state/threadReducer.test.ts+11-2
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22
33import {
44 CheckpointRef,
5 CommandId,
56 EventId,
67 MessageId,
78 ProjectId,
......@@ -123,8 +124,15 @@ describe("applyThreadDetailEvent", () => {
123124 });
124125
125126 describe("thread.archived / thread.unarchived", () => {
126 it("sets archivedAt", () => {
127 const result = applyThreadDetailEvent(baseThread, {
127 it("sets archivedAt and clears title regeneration", () => {
128 const regeneratingThread: OrchestrationThread = {
129 ...baseThread,
130 titleRegeneration: {
131 requestId: CommandId.make("regenerate-title"),
132 startedAt: "2026-04-01T02:00:00.000Z",
133 },
134 };
135 const result = applyThreadDetailEvent(regeneratingThread, {
128136 ...baseEventFields,
129137 sequence: 3,
130138 occurredAt: "2026-04-01T03:00:00.000Z",
......@@ -141,6 +149,7 @@ describe("applyThreadDetailEvent", () => {
141149 expect(result.kind).toBe("updated");
142150 if (result.kind === "updated") {
143151 expect(result.thread.archivedAt).toBe("2026-04-01T03:00:00.000Z");
152 expect(result.thread.titleRegeneration).toBeNull();
144153 }
145154 });
146155
packages/client-runtime/src/state/threadReducer.ts+4
......@@ -94,6 +94,7 @@ export function applyThreadDetailEvent(
9494 thread: {
9595 ...thread,
9696 archivedAt: event.payload.archivedAt,
97 titleRegeneration: null,
9798 updatedAt: event.payload.updatedAt,
9899 },
99100 };
......@@ -155,6 +156,9 @@ export function applyThreadDetailEvent(
155156 thread: {
156157 ...thread,
157158 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
159 ...(event.payload.titleRegeneration !== undefined
160 ? { titleRegeneration: event.payload.titleRegeneration }
161 : {}),
158162 ...(event.payload.modelSelection !== undefined
159163 ? { modelSelection: event.payload.modelSelection }
160164 : {}),
packages/contracts/src/environment.ts+3
......@@ -47,6 +47,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
4747 /** Server understands thread.snooze / thread.unsnooze commands. Same
4848 version-skew contract as threadSettlement. */
4949 threadSnooze: Schema.optionalKey(Schema.Boolean),
50 /** Server understands regenerateTitle on thread.meta.update. Absent on
51 older servers, so clients hide the action instead of sending it. */
52 threadTitleRegeneration: Schema.optionalKey(Schema.Boolean),
5053 /** The update path clients should offer for this server. Absent on
5154 servers that must be relaunched manually (dev checkouts, Windows
5255 foreground runs, pre-update servers). */
packages/contracts/src/orchestration.test.ts+55
......@@ -320,12 +320,20 @@ it.effect("decodes thread.meta-updated payloads with explicit provider", () =>
320320 Effect.gen(function* () {
321321 const parsed = yield* decodeThreadMetaUpdatedPayload({
322322 threadId: "thread-1",
323 regenerateTitle: true,
324 previousTitle: "Previous title",
325 titleRegeneration: {
326 requestId: "cmd-title-regenerate",
327 startedAt: "2026-01-01T00:00:00.000Z",
328 },
323329 modelSelection: {
324330 provider: "claudeAgent",
325331 model: "claude-opus-4-6",
326332 },
327333 updatedAt: "2026-01-01T00:00:00.000Z",
328334 });
335 assert.strictEqual(parsed.previousTitle, "Previous title");
336 assert.strictEqual(parsed.titleRegeneration?.requestId, "cmd-title-regenerate");
329337 assert.strictEqual(parsed.modelSelection?.instanceId, "claudeAgent");
330338 }),
331339);
......@@ -628,6 +636,53 @@ it.effect("accepts a title seed in thread.turn.start", () =>
628636 }),
629637);
630638
639it.effect("accepts a title regeneration intent in thread.meta.update", () =>
640 Effect.gen(function* () {
641 const parsed = yield* decodeOrchestrationCommand({
642 type: "thread.meta.update",
643 commandId: "cmd-title-regenerate",
644 threadId: "thread-1",
645 regenerateTitle: true,
646 });
647 assert.strictEqual(parsed.type, "thread.meta.update");
648 if (parsed.type === "thread.meta.update") {
649 assert.strictEqual(parsed.regenerateTitle, true);
650 }
651 }),
652);
653
654it.effect("accepts an internal title regeneration completion", () =>
655 Effect.gen(function* () {
656 const parsed = yield* decodeOrchestrationCommand({
657 type: "thread.title.regeneration.complete",
658 commandId: "cmd-title-regeneration-complete",
659 threadId: "thread-1",
660 requestId: "cmd-title-regenerate",
661 title: "Updated title",
662 });
663 assert.strictEqual(parsed.type, "thread.title.regeneration.complete");
664 if (parsed.type === "thread.title.regeneration.complete") {
665 assert.strictEqual(parsed.requestId, "cmd-title-regenerate");
666 assert.strictEqual(parsed.title, "Updated title");
667 }
668 }),
669);
670
671it.effect("rejects an explicit title combined with title regeneration", () =>
672 Effect.gen(function* () {
673 const result = yield* Effect.exit(
674 decodeOrchestrationCommand({
675 type: "thread.meta.update",
676 commandId: "cmd-title-regenerate-with-title",
677 threadId: "thread-1",
678 title: "Explicit title",
679 regenerateTitle: true,
680 }),
681 );
682 assert.strictEqual(result._tag, "Failure");
683 }),
684);
685
631686it.effect("accepts a source proposed plan reference in thread.turn.start", () =>
632687 Effect.gen(function* () {
633688 const parsed = yield* decodeThreadTurnStartCommand({
packages/contracts/src/orchestration.ts+33-1
......@@ -341,6 +341,12 @@ export const OrchestrationLatestTurn = Schema.Struct({
341341});
342342export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type;
343343
344export const ThreadTitleRegeneration = Schema.Struct({
345 requestId: CommandId,
346 startedAt: IsoDateTime,
347});
348export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;
349
344350export const OrchestrationThread = Schema.Struct({
345351 id: ThreadId,
346352 projectId: ProjectId,
......@@ -366,6 +372,8 @@ export const OrchestrationThread = Schema.Struct({
366372 // Optional so payloads from pre-snooze servers still decode.
367373 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
368374 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
375 // Pending-only state. Optional so older servers remain compatible.
376 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
369377 deletedAt: Schema.NullOr(IsoDateTime),
370378 messages: Schema.Array(OrchestrationMessage),
371379 proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe(
......@@ -418,6 +426,7 @@ export const OrchestrationThreadShell = Schema.Struct({
418426 settledAt: Schema.NullOr(IsoDateTime).pipe(Schema.withDecodingDefault(Effect.succeed(null))),
419427 snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
420428 snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
429 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
421430 session: Schema.NullOr(OrchestrationSession),
422431 latestUserMessageAt: Schema.NullOr(IsoDateTime),
423432 hasPendingApprovals: Schema.Boolean,
......@@ -616,11 +625,18 @@ const ThreadMetaUpdateCommand = Schema.Struct({
616625 commandId: CommandId,
617626 threadId: ThreadId,
618627 title: Schema.optional(TrimmedNonEmptyString),
628 regenerateTitle: Schema.optional(Schema.Literal(true)),
619629 modelSelection: Schema.optional(ModelSelection),
620630 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
621631 expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
622632 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
623});
633}).check(
634 Schema.makeFilter(
635 (input) =>
636 !(input.title !== undefined && input.regenerateTitle === true) ||
637 "title and regenerateTitle cannot be specified together",
638 ),
639);
624640
625641const ThreadRuntimeModeSetCommand = Schema.Struct({
626642 type: Schema.Literal("thread.runtime-mode.set"),
......@@ -859,6 +875,14 @@ const ThreadRevertCompleteCommand = Schema.Struct({
859875 createdAt: IsoDateTime,
860876});
861877
878const ThreadTitleRegenerationCompleteCommand = Schema.Struct({
879 type: Schema.Literal("thread.title.regeneration.complete"),
880 commandId: CommandId,
881 threadId: ThreadId,
882 requestId: CommandId,
883 title: Schema.optional(TrimmedNonEmptyString),
884});
885
862886const InternalOrchestrationCommand = Schema.Union([
863887 ThreadSessionSetCommand,
864888 ThreadMessageAssistantDeltaCommand,
......@@ -867,6 +891,7 @@ const InternalOrchestrationCommand = Schema.Union([
867891 ThreadTurnDiffCompleteCommand,
868892 ThreadActivityAppendCommand,
869893 ThreadRevertCompleteCommand,
894 ThreadTitleRegenerationCompleteCommand,
870895]);
871896export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
872897
......@@ -999,6 +1024,13 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
9991024export const ThreadMetaUpdatedPayload = Schema.Struct({
10001025 threadId: ThreadId,
10011026 title: Schema.optional(TrimmedNonEmptyString),
1027 /** Intent marker consumed by the title-generation reactor. Keeping this on
1028 the existing event lets older clients safely ignore the new field. */
1029 regenerateTitle: Schema.optional(Schema.Literal(true)),
1030 /** Title at request time, used to avoid overwriting a later manual rename. */
1031 previousTitle: Schema.optional(TrimmedNonEmptyString),
1032 /** Pending state shared with clients. Null clears a matching request. */
1033 titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)),
10021034 modelSelection: Schema.optional(ModelSelection),
10031035 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
10041036 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),