Skip to content

Commit 3da17ed

Browse files
committed
Fixed Pi thread title showing prompt prefix; added message-derived draft title
Two fixes: 1. Pi thread title generation (server): Pi echoes the user message back as 'message_update' text_delta events before streaming its actual reply. The collector was accumulating all deltas regardless of role, so the prompt prefix 'Write a concise thread title for a coding conversation...' was being captured as the generated title. Fix: track message_start/message_end role to set inAssistantMessage flag and only accumulate text_delta events that arrive while inside an assistant message, mirroring the same pattern used in PiAdapter.stream.ts. The message_end fallback path for non-streaming responses is also scoped to the assistant role. 2. Draft thread title UX (web): new threads showed 'New thread' in the sidebar until the AI title arrived — which could be several seconds after the first message. Now, when submitting the first message on a draft thread, draftTitleFromMessage() derives a provisional title from the first 25 chars of the user's message (stripping slash-command prefixes). This title is used for bootstrap.createThread.title and also passed as titleSeed so the server knows it is safe to replace it once the AI-generated title is ready.
1 parent 238f19a commit 3da17ed

3 files changed

Lines changed: 58 additions & 19 deletions

File tree

apps/server/src/git/Layers/ProviderNativeThreadTitleGeneration.ts

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -402,36 +402,53 @@ export const generatePiThreadTitleNative = (
402402
new Promise<string>((resolve, reject) => {
403403
let collectedText = "";
404404
let settled = false;
405+
// Track whether we are inside an assistant message so we only collect
406+
// text deltas from Pi's reply — not from any user-message echoes.
407+
let inAssistantMessage = false;
405408

406409
const unsubscribe = rpcProcess.subscribe((message) => {
407410
if (!("type" in message)) return;
408411
const event = message as PiRpcStdoutEvent;
409412

410-
if (
413+
if (event.type === "message_start") {
414+
const role =
415+
typeof (event as { type: "message_start"; message: Record<string, unknown> })
416+
.message.role === "string"
417+
? (event as { type: "message_start"; message: Record<string, unknown> }).message
418+
.role
419+
: undefined;
420+
inAssistantMessage = role === "assistant";
421+
} else if (
411422
event.type === "message_update" &&
412423
"assistantMessageEvent" in event &&
413424
event.assistantMessageEvent?.type === "text_delta"
414425
) {
415-
collectedText += event.assistantMessageEvent.delta;
426+
if (inAssistantMessage) {
427+
collectedText += event.assistantMessageEvent.delta;
428+
}
416429
} else if (event.type === "message_end") {
417-
// Fallback: extract text from final message if streaming deltas weren't received
418430
const msg = (event as { type: "message_end"; message: Record<string, unknown> })
419431
.message;
420-
if (collectedText.length === 0) {
421-
const content = msg.content;
422-
if (typeof content === "string" && content.trim().length > 0) {
423-
collectedText = content;
424-
} else if (Array.isArray(content)) {
425-
for (const part of content) {
426-
if (
427-
part &&
428-
typeof part === "object" &&
429-
"type" in part &&
430-
part.type === "text" &&
431-
"text" in part &&
432-
typeof part.text === "string"
433-
) {
434-
collectedText += part.text;
432+
const role = typeof msg.role === "string" ? msg.role : undefined;
433+
if (role === "assistant") {
434+
inAssistantMessage = false;
435+
// Fallback: extract text from final message if streaming deltas weren't received
436+
if (collectedText.length === 0) {
437+
const content = msg.content;
438+
if (typeof content === "string" && content.trim().length > 0) {
439+
collectedText = content;
440+
} else if (Array.isArray(content)) {
441+
for (const part of content) {
442+
if (
443+
part &&
444+
typeof part === "object" &&
445+
"type" in part &&
446+
part.type === "text" &&
447+
"text" in part &&
448+
typeof part.text === "string"
449+
) {
450+
collectedText += part.text;
451+
}
435452
}
436453
}
437454
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10;
2929

3030
export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
3131

32+
const DRAFT_TITLE_MAX_CHARS = 25;
33+
34+
/**
35+
* Derives a short provisional thread title from the user's first message.
36+
* Takes the first non-empty line, strips leading slash-command tokens, and
37+
* truncates to DRAFT_TITLE_MAX_CHARS characters so the sidebar shows something
38+
* meaningful before the AI-generated title arrives.
39+
*/
40+
export function draftTitleFromMessage(messageText: string): string {
41+
const firstLine = messageText.trim().split(/\r?\n/)[0]?.trim() ?? "";
42+
// Strip leading slash commands (e.g. "/plan ", "/default ")
43+
const stripped = firstLine.replace(/^\/\w+\s*/, "").trim();
44+
const candidate = stripped.length > 0 ? stripped : firstLine;
45+
if (candidate.length === 0) return "New thread";
46+
return candidate.length <= DRAFT_TITLE_MAX_CHARS
47+
? candidate
48+
: `${candidate.slice(0, DRAFT_TITLE_MAX_CHARS - 1).trimEnd()}…`;
49+
}
50+
3251
export function buildLocalDraftThread(
3352
threadId: ThreadId,
3453
draftThread: DraftThreadState,

apps/web/src/components/chat/view/ChatView.sendTurn.logic.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
formatOutgoingPrompt,
2424
readFileAsDataUrl,
2525
cloneComposerImageForRetry,
26+
draftTitleFromMessage,
2627
} from "./ChatView.logic";
2728
import { appendTerminalContextsToPrompt } from "../../../lib/terminalContext";
2829
import { toastManager } from "../../ui/toast";
@@ -312,14 +313,15 @@ export function useOnSend(input: UseOnSendInput) {
312313
}
313314

314315
const turnAttachments = await turnAttachmentsPromise;
316+
const draftTitle = isDraft ? draftTitleFromMessage(promptForSend) : undefined;
315317
const bootstrap =
316318
isDraft || baseBranchForWorktree
317319
? {
318320
...(isDraft
319321
? {
320322
createThread: {
321323
projectId: project.id,
322-
title: thread.title,
324+
title: draftTitle ?? thread.title,
323325
modelSelection: threadCreateModelSelection,
324326
runtimeMode: runMode,
325327
interactionMode: interactMode,
@@ -357,6 +359,7 @@ export function useOnSend(input: UseOnSendInput) {
357359
interactionMode: interactMode,
358360
...(bootstrap ? { bootstrap } : {}),
359361
...(bootstrapSourceThreadId ? { bootstrapSourceThreadId } : {}),
362+
...(draftTitle ? { titleSeed: draftTitle } : {}),
360363
createdAt: messageCreatedAt,
361364
});
362365
if (bootstrapSourceThreadId) {

0 commit comments

Comments
 (0)