Skip to content

Commit 927ebf8

Browse files
authored
Add inline thread renaming with draft title persistence (#137)
- Reuse a shared title editor in the header and sidebar - Persist custom draft thread titles separately from branch context - Normalize empty titles to the default "New thread"
1 parent 8f61bfd commit 927ebf8

19 files changed

Lines changed: 987 additions & 379 deletions

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"dev": "bun run src/index.ts",
1919
"build": "node scripts/cli.ts build",
2020
"start": "node dist/index.mjs",
21-
"prepare": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || node ../../scripts/patch-effect-language-service.ts",
21+
"prepare": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || (node ../../scripts/patch-effect-language-service.ts && node ../../scripts/patch-effect-smol-peer-installs.mjs)",
2222
"typecheck": "tsc --noEmit",
2323
"test": "vitest run"
2424
},

apps/server/src/attachmentText.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import {
2-
PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
3-
type ChatFileAttachment,
4-
} from "@okcode/contracts";
1+
import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type ChatFileAttachment } from "@okcode/contracts";
52

63
const MAX_FILE_CONTEXT_TOTAL_CHARS = 80_000;
74
const MAX_FILE_CONTEXT_CHARS_PER_FILE = 24_000;
@@ -140,10 +137,7 @@ export function buildFileAttachmentContextText(input: {
140137
return input.baseText;
141138
}
142139

143-
const maxChars = Math.max(
144-
1,
145-
Math.floor(input.maxChars ?? PROVIDER_SEND_TURN_MAX_INPUT_CHARS),
146-
);
140+
const maxChars = Math.max(1, Math.floor(input.maxChars ?? PROVIDER_SEND_TURN_MAX_INPUT_CHARS));
147141
let result = input.baseText;
148142
let usedFileContextChars = 0;
149143
let omittedCount = 0;

apps/server/src/provider/Layers/ClaudeAdapter.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,11 @@ async function readFirstPromptText(
206206
if (next.done) {
207207
return undefined;
208208
}
209-
const content = next.value.message.content[0];
209+
const contentBlocks = next.value.message.content;
210+
if (typeof contentBlocks === "string") {
211+
return contentBlocks;
212+
}
213+
const content = contentBlocks[0];
210214
if (!content || content.type !== "text") {
211215
return undefined;
212216
}

apps/server/src/provider/Layers/ClaudeAdapter.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type SettingSource,
1919
type SDKUserMessage,
2020
} from "@anthropic-ai/claude-agent-sdk";
21+
import type { ContentBlockParam, MessageParam } from "@anthropic-ai/sdk/resources";
2122
import {
2223
ApprovalRequestId,
2324
type CanonicalItemType,
@@ -510,6 +511,11 @@ const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([
510511
"image/png",
511512
"image/webp",
512513
]);
514+
type ClaudeImageMimeType = "image/gif" | "image/jpeg" | "image/png" | "image/webp";
515+
516+
function isClaudeImageMimeType(value: string): value is ClaudeImageMimeType {
517+
return SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(value as ClaudeImageMimeType);
518+
}
513519
const CLAUDE_SETTING_SOURCES = [
514520
"user",
515521
"project",
@@ -532,23 +538,24 @@ function buildPromptText(input: ProviderSendTurnInput): string {
532538
}
533539

534540
function buildUserMessage(input: {
535-
readonly sdkContent: Array<Record<string, unknown>>;
541+
readonly sdkContent: Array<ContentBlockParam>;
536542
}): SDKUserMessage {
543+
const message: MessageParam = {
544+
role: "user",
545+
content: input.sdkContent,
546+
};
537547
return {
538548
type: "user",
539549
session_id: "",
540550
parent_tool_use_id: null,
541-
message: {
542-
role: "user",
543-
content: input.sdkContent,
544-
},
545-
} as SDKUserMessage;
551+
message,
552+
};
546553
}
547554

548555
function buildClaudeImageContentBlock(input: {
549-
readonly mimeType: string;
556+
readonly mimeType: ClaudeImageMimeType;
550557
readonly bytes: Uint8Array;
551-
}): Record<string, unknown> {
558+
}): ContentBlockParam {
552559
return {
553560
type: "image",
554561
source: {
@@ -567,7 +574,9 @@ function buildUserMessageEffect(
567574
},
568575
): Effect.Effect<SDKUserMessage, ProviderAdapterRequestError> {
569576
return Effect.gen(function* () {
570-
const imageAttachments: Array<Extract<NonNullable<ProviderSendTurnInput["attachments"]>[number], { type: "image" }>> = [];
577+
const imageAttachments: Array<
578+
Extract<NonNullable<ProviderSendTurnInput["attachments"]>[number], { type: "image" }>
579+
> = [];
571580
const fileAttachments: Array<{
572581
readonly attachment: Extract<
573582
NonNullable<ProviderSendTurnInput["attachments"]>[number],
@@ -626,14 +635,14 @@ function buildUserMessageEffect(
626635
baseText: buildPromptText(input),
627636
attachments: fileAttachments,
628637
});
629-
const sdkContent: Array<Record<string, unknown>> = [];
638+
const sdkContent: Array<ContentBlockParam> = [];
630639

631640
if (text.length > 0) {
632641
sdkContent.push({ type: "text", text });
633642
}
634643

635644
for (const attachment of imageAttachments) {
636-
if (!SUPPORTED_CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType)) {
645+
if (!isClaudeImageMimeType(attachment.mimeType)) {
637646
return yield* new ProviderAdapterRequestError({
638647
provider: PROVIDER,
639648
method: "turn/start",

apps/server/src/provider/Layers/CodexAdapter.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1472,7 +1472,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
14721472
};
14731473
}),
14741474
{ concurrency: 1 },
1475-
).pipe(Effect.map((attachments) => attachments.filter((attachment) => attachment !== null)));
1475+
).pipe(
1476+
Effect.map((attachments) => attachments.filter((attachment) => attachment !== null)),
1477+
);
14761478

14771479
const turnInputText = buildFileAttachmentContextText({
14781480
baseText: input.input?.trim() ?? "",

apps/web/src/components/ChatView.browser.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
994994
[THREAD_ID]: {
995995
projectId: PROJECT_ID,
996996
createdAt: NOW_ISO,
997+
title: "New thread",
997998
runtimeMode: "full-access",
998999
interactionMode: "chat",
9991000
branch: null,
@@ -1051,6 +1052,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
10511052
[THREAD_ID]: {
10521053
projectId: PROJECT_ID,
10531054
createdAt: NOW_ISO,
1055+
title: "New thread",
10541056
runtimeMode: "full-access",
10551057
interactionMode: "chat",
10561058
branch: null,
@@ -1127,6 +1129,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
11271129
[THREAD_ID]: {
11281130
projectId: PROJECT_ID,
11291131
createdAt: NOW_ISO,
1132+
title: "New thread",
11301133
runtimeMode: "full-access",
11311134
interactionMode: "chat",
11321135
branch: "feature/draft",

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { ThreadId } from "@okcode/contracts";
1+
import { ProjectId, ThreadId } from "@okcode/contracts";
22
import { describe, expect, it } from "vitest";
33

44
import {
55
buildAutoSelectedWorktreeBaseBranchToastCopy,
6+
buildLocalDraftThread,
67
buildExpiredTerminalContextToastCopy,
78
deriveComposerSendState,
89
} from "./ChatView.logic";
@@ -96,3 +97,45 @@ describe("buildAutoSelectedWorktreeBaseBranchToastCopy", () => {
9697
});
9798
});
9899
});
100+
101+
describe("buildLocalDraftThread", () => {
102+
it("uses a persisted draft title when present", () => {
103+
const thread = buildLocalDraftThread(
104+
ThreadId.makeUnsafe("thread-draft"),
105+
{
106+
projectId: ProjectId.makeUnsafe("project-1"),
107+
createdAt: "2026-03-17T12:52:29.000Z",
108+
title: "Investigate flaky CI",
109+
runtimeMode: "full-access",
110+
interactionMode: "chat",
111+
branch: null,
112+
worktreePath: null,
113+
envMode: "local",
114+
},
115+
"gpt-5.4",
116+
null,
117+
);
118+
119+
expect(thread.title).toBe("Investigate flaky CI");
120+
});
121+
122+
it("falls back to the default title when the draft title is empty", () => {
123+
const thread = buildLocalDraftThread(
124+
ThreadId.makeUnsafe("thread-draft-empty"),
125+
{
126+
projectId: ProjectId.makeUnsafe("project-1"),
127+
createdAt: "2026-03-17T12:52:29.000Z",
128+
title: " ",
129+
runtimeMode: "full-access",
130+
interactionMode: "chat",
131+
branch: null,
132+
worktreePath: null,
133+
envMode: "local",
134+
},
135+
"gpt-5.4",
136+
null,
137+
);
138+
139+
expect(thread.title).toBe("New thread");
140+
});
141+
});

apps/web/src/components/ChatView.logic.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
stripInlineTerminalContextPlaceholders,
99
type TerminalContextDraft,
1010
} from "../lib/terminalContext";
11+
import { normalizeThreadTitle } from "../threadTitle";
1112

1213
export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "okcode:last-invoked-script-by-project";
1314
const WORKTREE_BRANCH_PREFIX = "okcode";
@@ -24,7 +25,7 @@ export function buildLocalDraftThread(
2425
id: threadId,
2526
codexThreadId: null,
2627
projectId: draftThread.projectId,
27-
title: "New thread",
28+
title: normalizeThreadTitle(draftThread.title),
2829
model: fallbackModel,
2930
runtimeMode: draftThread.runtimeMode,
3031
interactionMode: draftThread.interactionMode,

apps/web/src/components/ChatView.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ import { readDesktopPreviewBridge } from "~/desktopPreview";
214214
import { usePreviewStateStore } from "~/previewStateStore";
215215
import { useClientMode } from "~/hooks/useClientMode";
216216
import { useTransportState } from "~/hooks/useTransportState";
217+
import { hasCustomThreadTitle, normalizeThreadTitle } from "~/threadTitle";
217218

218219
const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
219220
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
@@ -436,6 +437,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
436437
);
437438
const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent);
438439
const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
440+
const setDraftThreadTitle = useComposerDraftStore((store) => store.setDraftThreadTitle);
439441
const getDraftThreadByProjectId = useComposerDraftStore(
440442
(store) => store.getDraftThreadByProjectId,
441443
);
@@ -2374,7 +2376,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
23742376
// Stage attachments in persisted draft state first so persist middleware can write them.
23752377
syncComposerDraftPersistedAttachments(threadId, serialized);
23762378
} catch {
2377-
const currentAttachmentIds = new Set(composerAttachments.map((attachment) => attachment.id));
2379+
const currentAttachmentIds = new Set(
2380+
composerAttachments.map((attachment) => attachment.id),
2381+
);
23782382
const fallbackPersistedAttachments = getPersistedAttachmentsForThread();
23792383
const fallbackPersistedIds = fallbackPersistedAttachments
23802384
.map((attachment) => attachment.id)
@@ -2523,7 +2527,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
25232527
nextQueued.text,
25242528
composerTerminalContextsSnapshot,
25252529
);
2526-
const fallbackOutgoingText = nextQueued.attachments.some((attachment) => attachment.type === "image")
2530+
const fallbackOutgoingText = nextQueued.attachments.some(
2531+
(attachment) => attachment.type === "image",
2532+
)
25272533
? IMAGE_ONLY_BOOTSTRAP_PROMPT
25282534
: "";
25292535
const outgoingMessageText = formatOutgoingPrompt({
@@ -3191,18 +3197,24 @@ export default function ChatView({ threadId }: ChatViewProps) {
31913197
}
31923198
}
31933199

3194-
const firstComposerAttachment = composerAttachmentsSnapshot[0] ?? null;
3195-
let titleSeed = trimmed;
3196-
if (!titleSeed) {
3197-
if (firstComposerAttachment) {
3198-
titleSeed = `${firstComposerAttachment.type === "image" ? "Image" : "File"}: ${firstComposerAttachment.name}`;
3199-
} else if (composerTerminalContextsSnapshot.length > 0) {
3200-
titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
3201-
} else {
3202-
titleSeed = "New thread";
3200+
const manualThreadTitle = hasCustomThreadTitle(activeThread.title)
3201+
? normalizeThreadTitle(activeThread.title)
3202+
: null;
3203+
let title = manualThreadTitle;
3204+
if (!title) {
3205+
const firstComposerAttachment = composerAttachmentsSnapshot[0] ?? null;
3206+
let titleSeed = trimmed;
3207+
if (!titleSeed) {
3208+
if (firstComposerAttachment) {
3209+
titleSeed = `${firstComposerAttachment.type === "image" ? "Image" : "File"}: ${firstComposerAttachment.name}`;
3210+
} else if (composerTerminalContextsSnapshot.length > 0) {
3211+
titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!);
3212+
} else {
3213+
titleSeed = normalizeThreadTitle(null);
3214+
}
32033215
}
3216+
title = truncateTitle(titleSeed);
32043217
}
3205-
const title = truncateTitle(titleSeed);
32063218
let threadCreateModel: ModelSlug =
32073219
selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex;
32083220

@@ -3249,7 +3261,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
32493261
}
32503262

32513263
// Auto-title from first message
3252-
if (isFirstMessage && isServerThread) {
3264+
if (isFirstMessage && isServerThread && !hasCustomThreadTitle(activeThread.title)) {
32533265
await api.orchestration.dispatchCommand({
32543266
type: "thread.meta.update",
32553267
commandId: newCommandId(),
@@ -4327,6 +4339,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
43274339
activeProjectName={activeProject?.name}
43284340
activeProjectCwd={activeProject?.cwd}
43294341
isGitRepo={isGitRepo}
4342+
isLocalDraftThread={isLocalDraftThread}
43304343
openInCwd={gitCwd}
43314344
activeProjectScripts={activeProject?.scripts}
43324345
preferredScriptId={
@@ -4343,6 +4356,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
43434356
gitCwd={gitCwd}
43444357
diffOpen={diffOpen}
43454358
clientMode={clientMode}
4359+
onRenameDraftThreadTitle={(title) => {
4360+
setDraftThreadTitle(activeThread.id, title);
4361+
}}
43464362
onRunProjectScript={(script) => {
43474363
void runProjectScript(script);
43484364
}}

0 commit comments

Comments
 (0)