From 2ba4f218a27e004b97a99220b1c2f5aab12bbccf Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 20 Mar 2026 20:00:18 -0300 Subject: [PATCH 1/3] feat(threads): add title summary generation and state tracking --- .../orchestrationEngine.integration.test.ts | 2 + .../Layers/CheckpointDiffQuery.test.ts | 1 + .../git/Layers/CodexTextGeneration.test.ts | 20 +++++ .../src/git/Layers/CodexTextGeneration.ts | 75 ++++++++++++++++++- apps/server/src/git/Layers/GitManager.test.ts | 19 +++++ .../server/src/git/Services/TextGeneration.ts | 20 +++++ .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/OrchestrationEngine.test.ts | 10 +++ .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 2 + .../Layers/ProviderCommandReactor.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.test.ts | 6 ++ .../Layers/ProviderRuntimeIngestion.ts | 1 + .../orchestration/commandInvariants.test.ts | 4 + .../decider.projectScripts.test.ts | 3 + apps/server/src/orchestration/decider.ts | 4 + apps/server/src/orchestration/projector.ts | 4 + .../persistence/Layers/ProjectionThreads.ts | 5 ++ apps/server/src/persistence/Migrations.ts | 2 + .../016_ProjectionThreadsTitleSummaryState.ts | 11 +++ .../persistence/Services/ProjectionThreads.ts | 2 + apps/server/src/wsServer.ts | 54 +++++++++++++ packages/contracts/src/ipc.ts | 9 ++- packages/contracts/src/orchestration.ts | 14 ++++ packages/contracts/src/server.ts | 13 +++- packages/contracts/src/ws.ts | 4 +- 27 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/016_ProjectionThreadsTitleSummaryState.ts diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 104a6e5e00..09e794b855 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -126,6 +126,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", + titleSummaryState: "missing", model: defaultModel, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -265,6 +266,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( threadId: THREAD_ID, projectId: PROJECT_ID, title: "Integration Thread", + titleSummaryState: "missing", model: "gpt-5.3-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 2f79ea9d5a..13ee6fbe1f 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -43,6 +43,7 @@ function makeSnapshot(input: { id: input.threadId, projectId: input.projectId, title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1cf2d0e092..24632bce25 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -292,6 +292,26 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("generates and sanitizes thread titles", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ' "Investigate sidebar thread naming regression"\nextra', + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate why thread names are hard to scan in the sidebar.", + }); + + expect(generated.title).toBe("Investigate sidebar thread naming regression"); + }), + ), + ); + it.effect("omits attachment metadata section when no attachments are provided", () => withFakeCodexEnv( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index a444627c3f..fd8c440e75 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -14,6 +14,7 @@ import { type BranchNameGenerationResult, type CommitMessageGenerationResult, type PrContentGenerationResult, + type ThreadTitleGenerationResult, type TextGenerationShape, TextGeneration, } from "../Services/TextGeneration.ts"; @@ -95,6 +96,18 @@ function sanitizePrTitle(raw: string): string { return "Update project changes"; } +function sanitizeThreadTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const normalized = singleLine.replace(/^["'`]+|["'`]+$/g, "").trim(); + if (normalized.length === 0) { + return "New thread"; + } + if (normalized.length <= 60) { + return normalized; + } + return normalized.slice(0, 60).trimEnd(); +} + const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -148,7 +161,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); const materializeImageAttachments = ( - _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + _operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", attachments: BranchNameGenerationInput["attachments"], ): Effect.Effect => Effect.gen(function* () { @@ -189,7 +206,11 @@ const makeCodexTextGeneration = Effect.gen(function* () { cleanupPaths = [], model, }: { - operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; cwd: string; prompt: string; outputSchemaJson: S; @@ -462,10 +483,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { }); }; + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = (input) => + Effect.gen(function* () { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const attachmentLines = (input.attachments ?? []).map( + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const promptSections = [ + "You write concise chat thread titles.", + "Return a JSON object with key: title.", + "Rules:", + "- Title must describe the user's request clearly.", + "- Keep it short and specific.", + "- Use sentence case or title case.", + "- No surrounding quotes.", + "- Max 60 characters.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "Thread context:", + limitSection(input.message, 12_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt: promptSections.join("\n"), + outputSchemaJson: Schema.Struct({ + title: Schema.String, + }), + imagePaths, + ...(input.model ? { model: input.model } : {}), + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + return { generateCommitMessage, generatePrContent, generateBranchName, + generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 8c72941cd0..07aa83dca5 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -63,6 +63,10 @@ interface FakeGitTextGeneration { cwd: string; message: string; }) => Effect.Effect<{ branch: string }, TextGenerationError>; + generateThreadTitle: (input: { + cwd: string; + message: string; + }) => Effect.Effect<{ title: string }, TextGenerationError>; } type FakePullRequest = NonNullable; @@ -166,6 +170,10 @@ function createTextGeneration(overrides: Partial = {}): T Effect.succeed({ branch: "update-workflow", }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), ...overrides, }; @@ -203,6 +211,17 @@ function createTextGeneration(overrides: Partial = {}): T }), ), ), + generateThreadTitle: (input) => + implementation.generateThreadTitle(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index b4650ed570..46cf29c9c6 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -58,12 +58,25 @@ export interface BranchNameGenerationResult { branch: string; } +export interface ThreadTitleGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; + /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + model?: string; +} + +export interface ThreadTitleGenerationResult { + title: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, ): Promise; generatePrContent(input: PrContentGenerationInput): Promise; generateBranchName(input: BranchNameGenerationInput): Promise; + generateThreadTitle(input: ThreadTitleGenerationInput): Promise; } /** @@ -90,6 +103,13 @@ export interface TextGenerationShape { readonly generateBranchName: ( input: BranchNameGenerationInput, ) => Effect.Effect; + + /** + * Generate a concise thread title from thread message context. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 4d339ba72e..52faae0465 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -288,6 +288,7 @@ describe("CheckpointReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 181b18d60c..0d90b2915d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -80,6 +80,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -134,6 +135,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-replay"), projectId: asProjectId("project-replay"), title: "replay", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -196,6 +198,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-stream"), projectId: asProjectId("project-stream"), title: "domain-stream", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -241,6 +244,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-turn-diff"), projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -350,6 +354,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-fail"), projectId: asProjectId("project-flaky"), title: "flaky-fail", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -367,6 +372,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-flaky-ok"), projectId: asProjectId("project-flaky"), title: "flaky-ok", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -432,6 +438,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-atomic"), projectId: asProjectId("project-atomic"), title: "atomic", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -567,6 +574,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-sync"), projectId: asProjectId("project-sync"), title: "sync-before", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -647,6 +655,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -664,6 +673,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.makeUnsafe("thread-duplicate"), projectId: asProjectId("project-duplicate"), title: "duplicate", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d46764cc8c..ae81f12018 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -420,6 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + titleSummaryState: event.payload.titleSummaryState, model: event.payload.model, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -442,6 +443,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.titleSummaryState !== undefined + ? { titleSummaryState: event.payload.titleSummaryState } + : {}), ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), ...(event.payload.worktreePath !== undefined diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index b5b73fd6e0..34f637e45b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -254,6 +254,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: "default", runtimeMode: "full-access", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 849d2fa3b6..370923f54e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -160,6 +160,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_summary_state AS "titleSummaryState", model, runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -546,6 +547,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + titleSummaryState: row.titleSummaryState, model: row.model, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index f1cf6afc91..cb780f540e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -243,6 +243,7 @@ describe("ProviderCommandReactor", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + titleSummaryState: "missing", model: threadModel, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c1ba48108f..f3f28578e3 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -198,6 +198,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -724,6 +725,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: "plan", runtimeMode: "approval-required", @@ -756,6 +758,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -905,6 +908,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: "plan", runtimeMode: "approval-required", @@ -1055,6 +1059,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: sourceThreadId, projectId: asProjectId("project-1"), title: "Plan Source", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: "plan", runtimeMode: "approval-required", @@ -1087,6 +1092,7 @@ describe("ProviderRuntimeIngestion", () => { threadId: targetThreadId, projectId: asProjectId("project-1"), title: "Plan Target", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3df47941af..b11f7af1af 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1155,6 +1155,7 @@ const make = Effect.gen(function* () { commandId: providerCommandId(event, "thread-meta-update"), threadId: thread.id, title: event.payload.name, + titleSummaryState: "generated", }); } diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index f95e4db754..3009ee36f9 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -50,6 +50,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", @@ -69,6 +70,7 @@ const readModel: OrchestrationReadModel = { id: ThreadId.makeUnsafe("thread-2"), projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", @@ -144,6 +146,7 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-3"), projectId: ProjectId.makeUnsafe("project-a"), title: "new", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", @@ -165,6 +168,7 @@ describe("commandInvariants", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: ProjectId.makeUnsafe("project-a"), title: "dup", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 516d8b2a28..c75959532e 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -136,6 +136,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -243,6 +244,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", @@ -322,6 +324,7 @@ describe("decider project scripts", () => { threadId: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6ea4c51759..2c1e174790 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -156,6 +156,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, + titleSummaryState: command.titleSummaryState, model: command.model, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -207,6 +208,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.titleSummaryState !== undefined + ? { titleSummaryState: command.titleSummaryState } + : {}), ...(command.model !== undefined ? { model: command.model } : {}), ...(command.branch !== undefined ? { branch: command.branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a677..40f9e7ae05 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -252,6 +252,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + titleSummaryState: payload.titleSummaryState, model: payload.model, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, @@ -295,6 +296,9 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.titleSummaryState !== undefined + ? { titleSummaryState: payload.titleSummaryState } + : {}), ...(payload.model !== undefined ? { model: payload.model } : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 10192697d0..e3a4878327 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -23,6 +23,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + title_summary_state, model, runtime_mode, interaction_mode, @@ -37,6 +38,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, + ${row.titleSummaryState}, ${row.model}, ${row.runtimeMode}, ${row.interactionMode}, @@ -51,6 +53,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + title_summary_state = excluded.title_summary_state, model = excluded.model, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -72,6 +75,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_summary_state AS "titleSummaryState", model, runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -95,6 +99,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_summary_state AS "titleSummaryState", model, runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ea1821014a..e3c3cfe860 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -27,6 +27,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; +import Migration0016 from "./Migrations/016_ProjectionThreadsTitleSummaryState.ts"; import { Effect } from "effect"; /** @@ -55,6 +56,7 @@ const loader = Migrator.fromRecord({ "13_ProjectionThreadProposedPlans": Migration0013, "14_ProjectionThreadProposedPlanImplementation": Migration0014, "15_ProjectionTurnsSourceProposedPlan": Migration0015, + "16_ProjectionThreadsTitleSummaryState": Migration0016, }); /** diff --git a/apps/server/src/persistence/Migrations/016_ProjectionThreadsTitleSummaryState.ts b/apps/server/src/persistence/Migrations/016_ProjectionThreadsTitleSummaryState.ts new file mode 100644 index 0000000000..c28263376c --- /dev/null +++ b/apps/server/src/persistence/Migrations/016_ProjectionThreadsTitleSummaryState.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_summary_state TEXT NOT NULL DEFAULT 'missing' + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 7a30870f2d..776b7e58d8 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -11,6 +11,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadTitleSummaryState, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -23,6 +24,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + titleSummaryState: ThreadTitleSummaryState, model: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..78c210a0a6 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -60,6 +60,7 @@ import { clamp } from "effect/Number"; import { Open, resolveAvailableEditors } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; +import { TextGeneration } from "./git/Services/TextGeneration.ts"; import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; import { ATTACHMENTS_ROUTE_PREFIX, @@ -214,6 +215,7 @@ export type ServerRuntimeServices = | ServerCoreRuntimeServices | GitManager | GitCore + | TextGeneration | TerminalManager | Keybindings | Open @@ -255,6 +257,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; const git = yield* GitCore; + const textGeneration = yield* TextGeneration; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -664,6 +667,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< threadId, projectId: bootstrapProjectId, title: "New thread", + titleSummaryState: "missing", model: bootstrapProjectDefaultModel, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "full-access", @@ -689,6 +693,51 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< >(); const runPromise = Effect.runPromiseWith(runtimeServices); + const generateThreadTitle = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly model?: string | undefined; + }) { + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.id === input.threadId); + if (!thread || thread.deletedAt !== null) { + return yield* new RouteRequestError({ + message: `Thread not found: ${input.threadId}`, + }); + } + + const project = snapshot.projects.find( + (entry) => entry.id === thread.projectId && entry.deletedAt === null, + ); + if (!project) { + return yield* new RouteRequestError({ + message: `Project not found for thread: ${input.threadId}`, + }); + } + + const userMessages = thread.messages.filter((message) => message.role === "user"); + if (userMessages.length === 0) { + return yield* new RouteRequestError({ + message: "Thread has no user messages to summarize.", + }); + } + + const threadContext = userMessages + .slice(-6) + .map((entry, index) => { + const header = userMessages.length > 1 ? `User message ${index + 1}:` : "User message:"; + return `${header}\n${entry.text}`; + }) + .join("\n\n"); + const attachments = userMessages.flatMap((entry) => entry.attachments ?? []); + + return yield* textGeneration.generateThreadTitle({ + cwd: project.workspaceRoot, + message: threadContext, + ...(attachments.length > 0 ? { attachments } : {}), + ...(input.model ? { model: input.model } : {}), + }); + }); + const unsubscribeTerminalEvents = yield* terminalManager.subscribe( (event) => void Effect.runPromise(pushBus.publishAll(WS_CHANNELS.terminalEvent, event)), ); @@ -877,6 +926,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< availableEditors, }; + case WS_METHODS.serverGenerateThreadTitle: { + const body = stripRequestTag(request.body); + return yield* generateThreadTitle(body); + } + case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..ec978cdff4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -24,7 +24,11 @@ import type { ProjectWriteFileInput, ProjectWriteFileResult, } from "./project"; -import type { ServerConfig } from "./server"; +import type { + ServerConfig, + ServerGenerateThreadTitleInput, + ServerGenerateThreadTitleResult, +} from "./server"; import type { TerminalClearInput, TerminalCloseInput, @@ -158,6 +162,9 @@ export interface NativeApi { }; server: { getConfig: () => Promise; + generateThreadTitle: ( + input: ServerGenerateThreadTitleInput, + ) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; }; orchestration: { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3208adc8bb..de8576e5f9 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -269,10 +269,16 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ThreadTitleSummaryState = Schema.Literals(["missing", "generated", "manual"]); +export type ThreadTitleSummaryState = typeof ThreadTitleSummaryState.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + titleSummaryState: ThreadTitleSummaryState.pipe( + Schema.withDecodingDefault(() => "missing" as const satisfies ThreadTitleSummaryState), + ), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -332,6 +338,9 @@ const ThreadCreateCommand = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + titleSummaryState: ThreadTitleSummaryState.pipe( + Schema.withDecodingDefault(() => "missing" as const satisfies ThreadTitleSummaryState), + ), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -353,6 +362,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + titleSummaryState: Schema.optional(ThreadTitleSummaryState), model: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -634,6 +644,9 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + titleSummaryState: ThreadTitleSummaryState.pipe( + Schema.withDecodingDefault(() => "missing" as const satisfies ThreadTitleSummaryState), + ), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( @@ -653,6 +666,7 @@ export const ThreadDeletedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + titleSummaryState: Schema.optional(ThreadTitleSummaryState), model: Schema.optional(TrimmedNonEmptyString), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f5..af49c75a4c 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,5 +1,5 @@ import { Schema } from "effect"; -import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; +import { IsoDateTime, ThreadId, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; @@ -64,6 +64,17 @@ export const ServerUpsertKeybindingResult = Schema.Struct({ }); export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; +export const ServerGenerateThreadTitleInput = Schema.Struct({ + threadId: ThreadId, + model: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerGenerateThreadTitleInput = typeof ServerGenerateThreadTitleInput.Type; + +export const ServerGenerateThreadTitleResult = Schema.Struct({ + title: TrimmedNonEmptyString, +}); +export type ServerGenerateThreadTitleResult = typeof ServerGenerateThreadTitleResult.Type; + export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviderStatuses, diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b8..f83166ef6c 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -36,7 +36,7 @@ import { import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; -import { ServerConfigUpdatedPayload } from "./server"; +import { ServerConfigUpdatedPayload, ServerGenerateThreadTitleInput } from "./server"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -74,6 +74,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", + serverGenerateThreadTitle: "server.generateThreadTitle", serverUpsertKeybinding: "server.upsertKeybinding", } as const; @@ -138,6 +139,7 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverGenerateThreadTitle, ServerGenerateThreadTitleInput), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), ]); From fb5ed942a3d876df2bd4c68e8307287c0e08f154 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 20 Mar 2026 20:00:28 -0300 Subject: [PATCH 2/3] feat(threads): add title summary settings and renaming UI --- apps/web/src/appSettings.test.ts | 3 + apps/web/src/appSettings.ts | 14 ++- apps/web/src/components/ChatView.browser.tsx | 2 + apps/web/src/components/ChatView.logic.ts | 1 + apps/web/src/components/ChatView.tsx | 37 +++++-- .../components/KeybindingsToast.browser.tsx | 1 + apps/web/src/components/Sidebar.tsx | 20 ++++ apps/web/src/routes/_chat.settings.tsx | 99 ++++++++++++++++++- apps/web/src/store.test.ts | 2 + apps/web/src/store.ts | 1 + apps/web/src/threadTitleGeneration.ts | 28 ++++++ apps/web/src/types.ts | 2 + apps/web/src/wsNativeApi.test.ts | 16 +++ apps/web/src/wsNativeApi.ts | 2 + 14 files changed, 214 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/threadTitleGeneration.ts diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 26d231537d..811f7b2ef8 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; +import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { AppSettingsSchema, @@ -215,8 +216,10 @@ describe("AppSettingsSchema", () => { confirmThreadDelete: false, enableAssistantStreaming: false, timestampFormat: DEFAULT_TIMESTAMP_FORMAT, + titleSummaryMode: "automatic", customCodexModels: [], customClaudeModels: [], + titleSummaryModel: DEFAULT_GIT_TEXT_GENERATION_MODEL, }); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92d..d994cf2793 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,10 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; -import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL, + TrimmedNonEmptyString, + type ProviderKind, +} from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, @@ -17,6 +21,8 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const TitleSummaryMode = Schema.Literals(["automatic", "off"]); +export type TitleSummaryMode = typeof TitleSummaryMode.Type; type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; @@ -53,9 +59,15 @@ export const AppSettingsSchema = Schema.Struct({ confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), + titleSummaryMode: TitleSummaryMode.pipe( + withDefaults(() => "automatic" as const satisfies TitleSummaryMode), + ), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), + titleSummaryModel: TrimmedNonEmptyString.pipe( + withDefaults(() => DEFAULT_GIT_TEXT_GENERATION_MODEL), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 48c627747d..082664e58c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -232,6 +232,7 @@ function createSnapshotForTargetUser(options: { id: THREAD_ID, projectId: PROJECT_ID, title: "Browser test thread", + titleSummaryState: "missing", model: "gpt-5", interactionMode: "default", runtimeMode: "full-access", @@ -286,6 +287,7 @@ function addThreadToSnapshot( id: threadId, projectId: PROJECT_ID, title: "New thread", + titleSummaryState: "missing", model: "gpt-5", interactionMode: "default", runtimeMode: "full-access", diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ddc84718e6..0e5a5ed7e9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -25,6 +25,7 @@ export function buildLocalDraftThread( codexThreadId: null, projectId: draftThread.projectId, title: "New thread", + titleSummaryState: "missing", model: fallbackModel, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..d7aa4f7d98 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -178,6 +178,7 @@ import { SendPhase, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; +import { generateAndRenameThreadTitle } from "~/threadTitleGeneration"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -2342,6 +2343,21 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); + const maybeGenerateFirstThreadTitle = useCallback( + async (input: { api: NonNullable>; threadId: ThreadId }) => { + if (settings.titleSummaryMode !== "automatic") { + return; + } + + await generateAndRenameThreadTitle({ + api: input.api, + threadId: input.threadId, + ...(settings.titleSummaryModel ? { model: settings.titleSummaryModel } : {}), + }); + }, + [settings.titleSummaryMode, settings.titleSummaryModel], + ); + const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readNativeApi(); @@ -2548,6 +2564,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: threadIdForSend, projectId: activeProject.id, title, + titleSummaryState: "missing", model: threadCreateModel, runtimeMode, interactionMode, @@ -2584,16 +2601,6 @@ export default function ChatView({ threadId }: ChatViewProps) { } } - // Auto-title from first message - if (isFirstMessage && isServerThread) { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId: threadIdForSend, - title, - }); - } - if (isServerThread) { await persistThreadSettingsForNextTurn({ threadId: threadIdForSend, @@ -2628,6 +2635,15 @@ export default function ChatView({ threadId }: ChatViewProps) { createdAt: messageCreatedAt, }); turnStartSucceeded = true; + if (isFirstMessage) { + void maybeGenerateFirstThreadTitle({ api, threadId: threadIdForSend }).catch((error) => { + toastManager.add({ + type: "error", + title: "Failed to generate title summary", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + } })().catch(async (err: unknown) => { if (createdServerThreadForLocalDraft && !turnStartSucceeded) { await api.orchestration @@ -3007,6 +3023,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, + titleSummaryState: "missing", model: nextThreadModel, runtimeMode, interactionMode: "default", diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba4c8f4320..b2347b38ed 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -76,6 +76,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { id: THREAD_ID, projectId: PROJECT_ID, title: "Test thread", + titleSummaryState: "missing", model: "gpt-5", interactionMode: "default", runtimeMode: "full-access", diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9ff741897c..d35a5bfccb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -46,6 +46,7 @@ import { derivePendingApprovals, derivePendingUserInputs } from "../session-logi import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; +import { generateAndRenameThreadTitle } from "../threadTitleGeneration"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; @@ -536,6 +537,7 @@ export default function Sidebar() { commandId: newCommandId(), threadId, title: trimmed, + titleSummaryState: "manual", }); } catch (error) { toastManager.add({ @@ -709,6 +711,7 @@ export default function Sidebar() { const clicked = await api.contextMenu.show( [ { id: "rename", label: "Rename thread" }, + { id: "summarize-rename", label: "Generate title summary" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, @@ -728,6 +731,22 @@ export default function Sidebar() { markThreadUnread(threadId); return; } + if (clicked === "summarize-rename") { + try { + await generateAndRenameThreadTitle({ + api, + threadId, + ...(appSettings.titleSummaryModel ? { model: appSettings.titleSummaryModel } : {}), + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to generate title summary", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } if (clicked === "copy-path") { if (!threadWorkspacePath) { toastManager.add({ @@ -760,6 +779,7 @@ export default function Sidebar() { }, [ appSettings.confirmThreadDelete, + appSettings.titleSummaryModel, copyPathToClipboard, copyThreadIdToClipboard, deleteThread, diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index acc8763fb4..ea1b27e5e3 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -85,6 +85,14 @@ function SettingsRouteView() { (option) => option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL), )?.name ?? settings.textGenerationModel; + const titleSummaryModelOptions = getAppModelOptions( + "codex", + settings.customCodexModels, + settings.titleSummaryModel, + ); + const selectedTitleSummaryModelLabel = + titleSummaryModelOptions.find((option) => option.slug === settings.titleSummaryModel)?.name ?? + settings.titleSummaryModel; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -480,7 +488,7 @@ function SettingsRouteView() {

Git

- Configure the model used for generating commit messages, PR titles, and branch + Configure the model used for AI-generated commit messages, PR titles, and branch names.

@@ -489,7 +497,7 @@ function SettingsRouteView() {

Text generation model

- Model used for auto-generated git content. + Model used for git text generation.

{ + if (value) { + updateSettings({ + titleSummaryModel: value, + }); + } + }} + > + + {selectedTitleSummaryModelLabel} + + + {titleSummaryModelOptions.map((option) => ( + + {option.name} + + ))} + + + + + {settings.titleSummaryModel !== defaults.titleSummaryModel ? ( +
+ +
+ ) : null} + +
+
+

Automatic title summaries

+

+ Automatically turn the first prompt into a short thread title using the title + summary model above. +

+
+ + updateSettings({ + titleSummaryMode: checked ? "automatic" : "off", + }) + } + aria-label="Automatically generate title summaries" + /> +
+ + {settings.titleSummaryMode !== defaults.titleSummaryMode ? ( +
+ +
+ ) : null}
diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index f1919ec724..4f35aeee8c 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -16,6 +16,7 @@ function makeThread(overrides: Partial = {}): Thread { codexThreadId: null, projectId: ProjectId.makeUnsafe("project-1"), title: "Thread", + titleSummaryState: "missing", model: "gpt-5-codex", runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, @@ -55,6 +56,7 @@ function makeReadModelThread(overrides: Partial { + const generated = await input.api.server.generateThreadTitle({ + threadId: input.threadId, + model: input.model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL, + }); + + await input.api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: input.threadId, + title: generated.title, + titleSummaryState: "generated", + }); + + return generated.title; +} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 32a7fe02b7..cee17423be 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -12,6 +12,7 @@ import type { ProviderKind, ProviderInteractionMode, RuntimeMode, + ThreadTitleSummaryState, } from "@t3tools/contracts"; export type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; @@ -90,6 +91,7 @@ export interface Thread { codexThreadId: string | null; projectId: ProjectId; title: string; + titleSummaryState?: ThreadTitleSummaryState | undefined; model: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da0..51c8060096 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -320,6 +320,22 @@ describe("wsNativeApi", () => { }); }); + it("forwards thread title generation requests to the websocket server method", async () => { + requestMock.mockResolvedValue({ title: "Fix sidebar naming" }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.server.generateThreadTitle({ + threadId: ThreadId.makeUnsafe("thread-1"), + model: "gpt-5.4-mini", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.serverGenerateThreadTitle, { + threadId: "thread-1", + model: "gpt-5.4-mini", + }); + }); + it("forwards full-thread diff requests to the orchestration websocket method", async () => { requestMock.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..32fd56b83f 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -159,6 +159,8 @@ export function createWsNativeApi(): NativeApi { }, server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), + generateThreadTitle: (input) => + transport.request(WS_METHODS.serverGenerateThreadTitle, input), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, orchestration: { From 267b38f268b9cb88c75cecae91a88ead9e9dcf50 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 20 Mar 2026 20:14:15 -0300 Subject: [PATCH 3/3] test(threads): update projector expectations for title summary state --- apps/server/src/orchestration/projector.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 71f5b6bd4b..c0e0e20a4f 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -73,6 +73,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", + titleSummaryState: "missing", model: "gpt-5-codex", runtimeMode: "full-access", interactionMode: "default",