Skip to content

Commit f3fa87e

Browse files
committed
fix(threads): share thread naming utilities and review auto-renames
1 parent dffe626 commit f3fa87e

9 files changed

Lines changed: 161 additions & 39 deletions

src/features/threads/hooks/threadReducer/common.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,7 @@
11
import type { ConversationItem } from "@/types";
22
import type { ThreadState } from "../useThreadsReducer";
3-
4-
const MAX_THREAD_NAME_LENGTH = 38;
5-
6-
function formatThreadName(text: string) {
7-
const trimmed = text.trim();
8-
if (!trimmed) {
9-
return null;
10-
}
11-
return trimmed.length > MAX_THREAD_NAME_LENGTH
12-
? `${trimmed.slice(0, MAX_THREAD_NAME_LENGTH)}…`
13-
: trimmed;
14-
}
15-
16-
export function looksAutoGeneratedThreadName(name: string) {
17-
return name === "New Agent" || name.startsWith("Agent ") || /^[a-f0-9]{4,8}$/i.test(name);
18-
}
3+
import { clampThreadName, looksAutoGeneratedThreadName } from "@threads/utils/threadNaming";
4+
export { looksAutoGeneratedThreadName } from "@threads/utils/threadNaming";
195

206
export function extractRenameText(text: string) {
217
if (!text) {
@@ -78,7 +64,7 @@ export function maybeRenameThreadFromAgent({
7864
if (hasCustomName) {
7965
return threadsByWorkspace;
8066
}
81-
const nextName = formatThreadName(getAssistantTextForRename(items, itemId));
67+
const nextName = clampThreadName(getAssistantTextForRename(items, itemId));
8268
if (!nextName) {
8369
return threadsByWorkspace;
8470
}

src/features/threads/hooks/useThreadMessaging.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,4 +487,58 @@ describe("useThreadMessaging telemetry", () => {
487487
"Turn steer failed: steer network failure",
488488
);
489489
});
490+
491+
it("names detached commit review child threads from commit context", async () => {
492+
vi.mocked(startReviewService).mockResolvedValueOnce({
493+
result: {
494+
review_thread_id: "thread-review-1",
495+
},
496+
} as unknown as Awaited<ReturnType<typeof startReviewService>>);
497+
const renameThread = vi.fn();
498+
499+
const { result } = renderHook(() =>
500+
useThreadMessaging({
501+
activeWorkspace: workspace,
502+
activeThreadId: "thread-parent",
503+
accessMode: "current",
504+
model: null,
505+
effort: null,
506+
collaborationMode: null,
507+
reviewDeliveryMode: "detached",
508+
steerEnabled: false,
509+
customPrompts: [],
510+
threadStatusById: {},
511+
activeTurnIdByThread: {},
512+
rateLimitsByWorkspace: {},
513+
pendingInterruptsRef: { current: new Set<string>() },
514+
dispatch: vi.fn(),
515+
getCustomName: vi.fn(() => undefined),
516+
markProcessing: vi.fn(),
517+
markReviewing: vi.fn(),
518+
setActiveTurnId: vi.fn(),
519+
recordThreadActivity: vi.fn(),
520+
safeMessageActivity: vi.fn(),
521+
onDebug: vi.fn(),
522+
pushThreadErrorMessage: vi.fn(),
523+
ensureThreadForActiveWorkspace: vi.fn(async () => "thread-parent"),
524+
ensureThreadForWorkspace: vi.fn(async () => "thread-parent"),
525+
refreshThread: vi.fn(async () => null),
526+
forkThreadForWorkspace: vi.fn(async () => null),
527+
updateThreadParent: vi.fn(),
528+
renameThread,
529+
}),
530+
);
531+
532+
await act(async () => {
533+
await result.current.startReview(
534+
"/review commit abcdef1234567890 Tighten sidebar commit selection",
535+
);
536+
});
537+
538+
expect(renameThread).toHaveBeenCalledWith(
539+
"ws-1",
540+
"thread-review-1",
541+
"Review abcdef1: Tighten sidebar commit…",
542+
);
543+
});
490544
});

src/features/threads/hooks/useThreadMessaging.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
extractRpcErrorMessage,
2929
parseReviewTarget,
3030
} from "@threads/utils/threadNormalize";
31+
import { clampThreadName } from "@threads/utils/threadNaming";
3132
import type { ThreadAction, ThreadState } from "./useThreadsReducer";
3233
import { useReviewPrompt } from "./useReviewPrompt";
3334
import { formatRelativeTime } from "@utils/time";
@@ -91,8 +92,30 @@ type UseThreadMessagingOptions = {
9192
parentId: string,
9293
childId: string,
9394
) => void;
95+
renameThread?: (workspaceId: string, threadId: string, name: string) => void;
9496
};
9597

98+
function buildReviewThreadTitle(target: ReviewTarget): string | null {
99+
if (target.type === "commit") {
100+
const shortSha = target.sha.trim().slice(0, 7);
101+
const title = target.title?.trim() ?? "";
102+
if (shortSha && title) {
103+
return clampThreadName(`Review ${shortSha}: ${title}`);
104+
}
105+
if (shortSha) {
106+
return clampThreadName(`Review ${shortSha}`);
107+
}
108+
return clampThreadName("Review Commit");
109+
}
110+
if (target.type === "baseBranch") {
111+
return clampThreadName(`Review ${target.branch}`);
112+
}
113+
if (target.type === "uncommittedChanges") {
114+
return "Review Working Tree";
115+
}
116+
return null;
117+
}
118+
96119
function isStaleSteerTurnError(message: string): boolean {
97120
const normalized = message.trim().toLowerCase();
98121
if (!normalized) {
@@ -135,6 +158,7 @@ export function useThreadMessaging({
135158
forkThreadForWorkspace,
136159
updateThreadParent,
137160
registerDetachedReviewChild,
161+
renameThread,
138162
}: UseThreadMessagingOptions) {
139163
const sendMessageToThread = useCallback(
140164
async (
@@ -568,6 +592,10 @@ export function useThreadMessaging({
568592
updateThreadParent(threadId, [reviewThreadId]);
569593
if (reviewDeliveryMode === "detached") {
570594
registerDetachedReviewChild?.(workspaceId, threadId, reviewThreadId);
595+
const reviewTitle = buildReviewThreadTitle(target);
596+
if (reviewTitle && !getCustomName(workspaceId, reviewThreadId)) {
597+
renameThread?.(workspaceId, reviewThreadId, reviewTitle);
598+
}
571599
}
572600
}
573601
return true;
@@ -595,6 +623,7 @@ export function useThreadMessaging({
595623
activeWorkspace,
596624
ensureThreadForActiveWorkspace,
597625
ensureThreadForWorkspace,
626+
getCustomName,
598627
markProcessing,
599628
markReviewing,
600629
onDebug,
@@ -603,6 +632,7 @@ export function useThreadMessaging({
603632
setActiveTurnId,
604633
reviewDeliveryMode,
605634
registerDetachedReviewChild,
635+
renameThread,
606636
updateThreadParent,
607637
],
608638
);

src/features/threads/hooks/useThreadTitleAutogeneration.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,23 @@ describe("useThreadTitleAutogeneration", () => {
109109
expect(generateRunMetadata).not.toHaveBeenCalled();
110110
});
111111

112+
it("treats UUID placeholder thread names as auto-generated", async () => {
113+
vi.mocked(generateRunMetadata).mockResolvedValue({
114+
title: "Commit review summary",
115+
worktreeName: "feat/review-summary",
116+
});
117+
const { result, renameThread } = setup({
118+
threadName: "019c9e0e-7f97-78f2-a719-d28af9fb76b6",
119+
});
120+
121+
await act(async () => {
122+
await result.current.onUserMessageCreated("ws-1", "thread-1", "Review this commit");
123+
});
124+
125+
expect(generateRunMetadata).toHaveBeenCalledWith("ws-1", "Review this commit");
126+
expect(renameThread).toHaveBeenCalledWith("ws-1", "thread-1", "Commit review summary");
127+
});
128+
112129
it("avoids duplicate generation while in flight", async () => {
113130
let resolvePromise!: (value: { title: string; worktreeName: string }) => void;
114131
const pending = new Promise<{ title: string; worktreeName: string }>((resolve) => {

src/features/threads/hooks/useThreadTitleAutogeneration.ts

Lines changed: 2 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,10 @@ import { useCallback, useRef } from "react";
22
import type { MutableRefObject } from "react";
33
import type { ConversationItem, DebugEntry, ThreadSummary } from "@/types";
44
import { generateRunMetadata } from "@services/tauri";
5+
import { clampThreadName, looksAutoGeneratedThreadName } from "@threads/utils/threadNaming";
56

6-
const MAX_THREAD_NAME_LENGTH = 38;
77
const MAX_PROMPT_CHARS = 1200;
88

9-
function looksAutoGeneratedThreadName(name: string) {
10-
return (
11-
name === "New Agent" ||
12-
name.startsWith("Agent ") ||
13-
/^[a-f0-9]{4,8}$/i.test(name)
14-
);
15-
}
16-
17-
function clampThreadTitle(title: string) {
18-
const trimmed = title.replace(/\s+/g, " ").trim();
19-
if (!trimmed) {
20-
return null;
21-
}
22-
return trimmed.length > MAX_THREAD_NAME_LENGTH
23-
? `${trimmed.slice(0, MAX_THREAD_NAME_LENGTH)}…`
24-
: trimmed;
25-
}
26-
279
function cleanPromptText(text: string) {
2810
if (!text) {
2911
return "";
@@ -93,7 +75,7 @@ export function useThreadTitleAutogeneration({
9375
inFlightRef.current[key] = true;
9476
try {
9577
const metadata = await generateRunMetadata(workspaceId, cleaned);
96-
const title = clampThreadTitle(metadata.title ?? "");
78+
const title = clampThreadName(metadata.title ?? "");
9779
if (!title) {
9880
return;
9981
}
@@ -125,4 +107,3 @@ export function useThreadTitleAutogeneration({
125107

126108
return { onUserMessageCreated };
127109
}
128-

src/features/threads/hooks/useThreadTurnEvents.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,26 @@ describe("useThreadTurnEvents", () => {
256256
});
257257
});
258258

259+
it("ignores placeholder thread names that mirror the thread id", () => {
260+
const { result, dispatch, getCustomName } = makeOptions();
261+
getCustomName.mockReturnValue(undefined);
262+
263+
act(() => {
264+
result.current.onThreadNameUpdated("ws-1", {
265+
threadId: "019c9e0e-7f97-78f2-a719-d28af9fb76b6",
266+
threadName: "019c9e0e-7f97-78f2-a719-d28af9fb76b6",
267+
});
268+
});
269+
270+
expect(dispatch).not.toHaveBeenCalledWith(
271+
expect.objectContaining({
272+
type: "setThreadName",
273+
workspaceId: "ws-1",
274+
threadId: "019c9e0e-7f97-78f2-a719-d28af9fb76b6",
275+
}),
276+
);
277+
});
278+
259279
it("does not override custom thread names on thread name updated", () => {
260280
const { result, dispatch, getCustomName } = makeOptions();
261281
getCustomName.mockReturnValue("Custom Name");

src/features/threads/hooks/useThreadTurnEvents.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
normalizeRateLimits,
1010
normalizeTokenUsage,
1111
} from "@threads/utils/threadNormalize";
12+
import { looksAutoGeneratedThreadName } from "@threads/utils/threadNaming";
1213
import {
1314
getParentThreadIdFromThread,
1415
isSubagentThreadSource,
@@ -159,14 +160,22 @@ export function useThreadTurnEvents({
159160
if (!threadId || !threadName) {
160161
return;
161162
}
163+
const normalizedThreadId = threadId.trim().toLowerCase();
164+
const normalizedThreadName = threadName.trim();
165+
if (
166+
normalizedThreadName.toLowerCase() === normalizedThreadId &&
167+
looksAutoGeneratedThreadName(normalizedThreadName)
168+
) {
169+
return;
170+
}
162171
if (getCustomName(workspaceId, threadId)) {
163172
return;
164173
}
165174
dispatch({
166175
type: "setThreadName",
167176
workspaceId,
168177
threadId,
169-
name: threadName,
178+
name: normalizedThreadName,
170179
});
171180
},
172181
[dispatch, getCustomName],

src/features/threads/hooks/useThreads.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -763,6 +763,7 @@ export function useThreads({
763763
forkThreadForWorkspace,
764764
updateThreadParent,
765765
registerDetachedReviewChild,
766+
renameThread,
766767
});
767768

768769
const hasLocalThreadSnapshot = useCallback(
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const UUID_LIKE_THREAD_NAME_REGEX =
2+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3+
4+
const SHORT_HEX_THREAD_NAME_REGEX = /^[a-f0-9]{4,8}$/i;
5+
6+
export function looksAutoGeneratedThreadName(name: string) {
7+
const trimmed = name.trim();
8+
return (
9+
trimmed === "New Agent" ||
10+
trimmed.startsWith("Agent ") ||
11+
SHORT_HEX_THREAD_NAME_REGEX.test(trimmed) ||
12+
UUID_LIKE_THREAD_NAME_REGEX.test(trimmed)
13+
);
14+
}
15+
16+
export function clampThreadName(name: string, maxLength = 38): string | null {
17+
const trimmed = name.replace(/\s+/g, " ").trim();
18+
if (!trimmed) {
19+
return null;
20+
}
21+
return trimmed.length > maxLength
22+
? `${trimmed.slice(0, maxLength)}…`
23+
: trimmed;
24+
}

0 commit comments

Comments
 (0)