Skip to content

Commit 40aa940

Browse files
authored
fix(sessions): name tasks from pasted text-file contents (#2850)
## Problem When you paste a prompt into PHCode it gets auto-converted to a `pasted-text.txt` attachment with **no typed text**, so the only signal for naming the task is the file's contents. The name generator wasn't reading them, so the task defaulted to **Untitled**. Tracing the flow, the breakage is **specific to cloud tasks**: - **Local tasks already work** — the prompt event carries the `<file path="…"/>` tag inline, which the title generator reads. - **Cloud tasks were broken** — by the time the generator runs, the original local path is gone: - the stored description is reduced to `Attached files: pasted-text.txt` (no path), and - the echoed prompt event points at the **remote sandbox path** (`file:///workspace/.posthog/attachments/…`), which can't be read on the user's machine. The local path only exists at **submit time**, before the file is uploaded as a cloud artifact — so the generator had only the filename and produced `Untitled`. Closes GROW-47 ## Fix 1. **`titleAttachmentStore.ts`** (new) — small UI store that stashes the prompt's local attachment paths keyed by task id. 2. **`useTaskCreation.ts`** — on task creation, stash those local paths for the new task id. 3. **`useChatTitleGenerator.ts`** — read the stashed paths, hand them to the title generator, and clear them once a title is produced. 4. **`titleGeneratorService.ts`** — widened the `Attached files:` matcher so the `1. [Attached files: …]` form (produced when titling from prompts) is recognized as "no real text", falling through to the file contents. The generator already truncates to 500 chars, skips binary files, and **ignores attachments when real text is typed** — so normal prompts are unaffected. This also makes the local path more robust (it now gets the explicit path too). ## Tests - `titleGeneratorService.test.ts` — cases for the cloud description form (`Attached files: …`) and the numbered prompt-list form, plus a guard that typed text still wins over attachments. - `useChatTitleGenerator.test.ts` — a cloud-style case asserting stashed local paths are passed through and cleared after naming; updated existing assertions for the new arg. ## Verification Typecheck, Biome lint (incl. `noRestrictedImports`), and a full `pnpm build` pass; sessions + task-detail suites green (435 tests). Did **not** run a live cloud end-to-end (needs auth + cloud backend + a real LLM call) — covered by unit tests instead. ## Known limitation If the app reloads in the few seconds between submitting and the title generating, the in-memory stash is lost and the title falls back to the attachment summary (same as other in-memory optimistic state). Can persist it if desired.
1 parent 21acadb commit 40aa940

6 files changed

Lines changed: 202 additions & 4 deletions

File tree

packages/core/src/sessions/titleGeneratorService.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,53 @@ describe("enrichDescriptionWithFileContent", () => {
8686
},
8787
);
8888

89+
it.each([
90+
{
91+
label: "cloud description summary",
92+
description: "Attached files: pasted-text.txt",
93+
},
94+
{
95+
label: "numbered prompt list item",
96+
description: "1. [Attached files: pasted-text.txt]",
97+
},
98+
])(
99+
"reads explicit file paths for attachment-only prompt -- $label",
100+
async ({ description }) => {
101+
readAbsoluteFile.mockResolvedValue(
102+
"Refactor the auth flow and add tests",
103+
);
104+
const result = await makeService().enrichDescriptionWithFileContent(
105+
description,
106+
["/tmp/clip/pasted-text.txt"],
107+
);
108+
expect(result).toBe("Refactor the auth flow and add tests");
109+
expect(readAbsoluteFile).toHaveBeenCalledWith(
110+
"/tmp/clip/pasted-text.txt",
111+
);
112+
},
113+
);
114+
115+
it("ignores explicit file paths when the prompt has real typed text", async () => {
116+
const description = "Fix the login bug\n\nAttached files: pasted-text.txt";
117+
const result = await makeService().enrichDescriptionWithFileContent(
118+
description,
119+
["/tmp/clip/pasted-text.txt"],
120+
);
121+
expect(result).toBe(description);
122+
expect(readAbsoluteFile).not.toHaveBeenCalled();
123+
});
124+
125+
it("does not strip user text that starts with 'Attached files:' but has no brackets", async () => {
126+
// "1. Attached files: xyz" (no brackets) is user-typed text, not a sentinel.
127+
const description = "1. Attached files: here is my task\n2. please fix it";
128+
const result = await makeService().enrichDescriptionWithFileContent(
129+
description,
130+
["/tmp/clip/pasted-text.txt"],
131+
);
132+
expect(result).toBe(description);
133+
expect(readAbsoluteFile).not.toHaveBeenCalled();
134+
});
135+
89136
it("truncates content longer than 500 chars", async () => {
90137
const longContent = "x".repeat(600);
91138
readAbsoluteFile.mockResolvedValue(longContent);

packages/core/src/sessions/titleGeneratorService.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ import {
1313
type TitleGeneratorLogger,
1414
} from "./titleGeneratorIdentifiers";
1515

16-
const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm;
16+
// Matches the attachment-summary sentinel we synthesize for prompts that carry
17+
// no typed text. Three forms need stripping:
18+
// "Attached files: a.txt" — bare description (cloud task.description)
19+
// "[Attached files: a.txt]" — bracketed session-event sentinel
20+
// "1. [Attached files: a.txt]" — numbered form from formatPromptsForTitleInput
21+
// The bracketed forms require a literal `[` so that user text like
22+
// "1. Attached files: my notes" (no brackets) is never stripped.
23+
const ATTACHED_FILES_REGEX =
24+
/^(?:(?:\d+\.\s*)?\[Attached files:[^\]]*\]|Attached files:.*)$/gm;
1725
const PASTED_TEXT_SNIPPET_LIMIT = 500;
1826

1927
const SYSTEM_PROMPT = `You are a title and summary generator. Output using exactly this format:

packages/ui/src/features/sessions/hooks/useChatTitleGenerator.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const mockSetQueryData = vi.hoisted(() => vi.fn());
1414
const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn());
1515
const mockPrompts = vi.hoisted(() => ({ value: [] as string[] }));
1616
const mockSessionStoreSetters = vi.hoisted(() => ({ updateSession: vi.fn() }));
17+
const mockTitleAttachmentPaths = vi.hoisted(() => ({ value: [] as string[] }));
18+
const mockTitleAttachmentClear = vi.hoisted(() => vi.fn());
1719

1820
vi.mock("@tanstack/react-query", () => ({
1921
useQueryClient: () => ({
@@ -64,6 +66,14 @@ vi.mock("@posthog/ui/shell/logger", () => ({
6466
},
6567
}));
6668

69+
vi.mock("@posthog/ui/shell/titleAttachmentStore", () => ({
70+
titleAttachmentStoreApi: {
71+
get: () => mockTitleAttachmentPaths.value,
72+
set: vi.fn(),
73+
clear: mockTitleAttachmentClear,
74+
},
75+
}));
76+
6777
vi.mock("@posthog/ui/features/sessions/sessionStore", () => {
6878
const state = {
6979
taskIdIndex: { "task-1": "run-1" },
@@ -108,6 +118,7 @@ describe("useChatTitleGenerator", () => {
108118
vi.clearAllMocks();
109119
mockIsAuthenticated.value = true;
110120
mockPrompts.value = [];
121+
mockTitleAttachmentPaths.value = [];
111122
mockEnrichDescription.mockImplementation((desc: string) =>
112123
Promise.resolve(desc),
113124
);
@@ -130,7 +141,10 @@ describe("useChatTitleGenerator", () => {
130141
renderHook(() => useChatTitleGenerator(createTask()));
131142

132143
await waitFor(() => {
133-
expect(mockEnrichDescription).toHaveBeenCalledWith("Fix the login bug");
144+
expect(mockEnrichDescription).toHaveBeenCalledWith(
145+
"Fix the login bug",
146+
[],
147+
);
134148
});
135149
await waitFor(() => {
136150
expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, {
@@ -266,11 +280,64 @@ describe("useChatTitleGenerator", () => {
266280
await waitFor(() => {
267281
expect(mockEnrichDescription).toHaveBeenCalledWith(
268282
'1. <file path="/tmp/code.ts" />',
283+
[],
269284
);
270285
expect(mockGenerateTitle).toHaveBeenCalledWith("enriched content");
271286
});
272287
});
273288

289+
it("passes stashed local attachment paths and clears them after naming", async () => {
290+
mockTitleAttachmentPaths.value = ["/tmp/clip/pasted-text.txt"];
291+
mockEnrichDescription.mockResolvedValue("Refactor the auth flow");
292+
mockGenerateTitle.mockResolvedValue({
293+
title: "Refactor auth flow",
294+
summary: "",
295+
});
296+
mockPrompts.value = ["[Attached files: pasted-text.txt]"];
297+
298+
renderHook(() =>
299+
useChatTitleGenerator(
300+
createTask({
301+
title: "",
302+
description: "Attached files: pasted-text.txt",
303+
}),
304+
),
305+
);
306+
307+
await waitFor(() => {
308+
expect(mockEnrichDescription).toHaveBeenCalledWith(
309+
"1. [Attached files: pasted-text.txt]",
310+
["/tmp/clip/pasted-text.txt"],
311+
);
312+
});
313+
await waitFor(() => {
314+
expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, {
315+
title: "Refactor auth flow",
316+
});
317+
});
318+
expect(mockTitleAttachmentClear).toHaveBeenCalledWith(TASK_ID);
319+
});
320+
321+
it("does not clear stashed paths when generation returns null (keeps them for prompt-path retry)", async () => {
322+
mockTitleAttachmentPaths.value = ["/tmp/clip/pasted-text.txt"];
323+
mockGenerateTitle.mockResolvedValue(null);
324+
mockPrompts.value = ["[Attached files: pasted-text.txt]"];
325+
326+
renderHook(() =>
327+
useChatTitleGenerator(
328+
createTask({
329+
title: "",
330+
description: "Attached files: pasted-text.txt",
331+
}),
332+
),
333+
);
334+
335+
await waitFor(() => {
336+
expect(mockGenerateTitle).toHaveBeenCalled();
337+
});
338+
expect(mockTitleAttachmentClear).not.toHaveBeenCalled();
339+
});
340+
274341
it("updates conversation summary when returned", async () => {
275342
mockGenerateTitle.mockResolvedValue({
276343
title: "Some title",

packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "@posthog/ui/features/sessions/sessionStore";
2121
import { taskKeys } from "@posthog/ui/features/tasks/taskKeys";
2222
import { logger } from "@posthog/ui/shell/logger";
23+
import { titleAttachmentStoreApi } from "@posthog/ui/shell/titleAttachmentStore";
2324
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
2425
import { useEffect, useRef } from "react";
2526

@@ -95,10 +96,18 @@ export function useChatTitleGenerator(task: Task): void {
9596

9697
const run = async () => {
9798
try {
98-
const content =
99-
await titleGenerator.enrichDescriptionWithFileContent(rawContent);
99+
const attachmentPaths = titleAttachmentStoreApi.get(taskId) ?? [];
100+
const content = await titleGenerator.enrichDescriptionWithFileContent(
101+
rawContent,
102+
attachmentPaths,
103+
);
100104
const result = await titleGenerator.generateTitleAndSummary(content);
101105
if (result) {
106+
// Drop the stash once a title has been successfully produced so the
107+
// map doesn't grow across a long-lived session. Keeping it on failure
108+
// lets the prompt-based regeneration at REGENERATE_INTERVAL pick it
109+
// up and try again with the file contents.
110+
titleAttachmentStoreApi.clear(taskId);
102111
const { title, summary } = result;
103112
const titleLocked = isAutoTitleLocked(
104113
getCachedTask(queryClient, taskId) ?? task,

packages/ui/src/features/task-detail/hooks/useTaskCreation.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { toast } from "../../../primitives/toast";
2626
import { track } from "../../../shell/analytics";
2727
import { logger } from "../../../shell/logger";
2828
import { pendingTaskPromptStoreApi } from "../../../shell/pendingTaskPromptStore";
29+
import { titleAttachmentStoreApi } from "../../../shell/titleAttachmentStore";
2930
import { useAuthStateValue } from "../../auth/store";
3031
import { assertCloudUsageAvailable } from "../../billing/preflightCloudUsage";
3132
import { useUsageLimitStore } from "../../billing/usageLimitStore";
@@ -318,6 +319,24 @@ export function useTaskCreation({
318319
input,
319320
(output) => {
320321
invalidateTasks(output.task);
322+
// Stash the prompt's local attachment paths so the chat-title
323+
// generator can read their contents when naming the task — needed
324+
// for pasted-text prompts whose only signal is the file body, and
325+
// especially for cloud tasks where the local path is otherwise lost
326+
// once the file is uploaded as an artifact.
327+
// Exclude folder chips — only file paths are readable by the title
328+
// generator's readAbsoluteFile call.
329+
const folderIds = new Set(
330+
content.segments.flatMap((seg) =>
331+
seg.type === "chip" && seg.chip.type === "folder"
332+
? [seg.chip.id]
333+
: [],
334+
),
335+
);
336+
const fileOnlyPaths = filePaths.filter((p) => !folderIds.has(p));
337+
if (fileOnlyPaths.length > 0) {
338+
titleAttachmentStoreApi.set(output.task.id, fileOnlyPaths);
339+
}
321340
if (signalReportId) {
322341
clearTaskInputReportAssociation();
323342
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { create } from "zustand";
2+
3+
/**
4+
* Local attachment file paths captured at task-creation time, keyed by task id,
5+
* so the chat-title generator can read their contents when naming a task.
6+
*
7+
* Why this exists: when a prompt is pasted as a text file (or otherwise sent as
8+
* an attachment with no typed text), the title has to come from the file's
9+
* contents. For local tasks the prompt event still carries the `<file .../>`
10+
* path, so the generator can read it directly. For cloud tasks it cannot — the
11+
* stored description is reduced to `Attached files: <name>` and the echoed
12+
* prompt event points at the remote sandbox path (e.g.
13+
* `file:///workspace/.posthog/attachments/...`), which is not readable on the
14+
* user's machine. The only place the original local path exists is at submit
15+
* time, so we stash it here and hand it to the generator, which reads the file
16+
* locally before it is cleaned up.
17+
*
18+
* Best-effort and in-memory: lost on reload, at which point the title falls back
19+
* to the attachment summary.
20+
*/
21+
interface TitleAttachmentStore {
22+
byTaskId: Record<string, string[]>;
23+
set: (taskId: string, filePaths: string[]) => void;
24+
get: (taskId: string) => string[] | undefined;
25+
clear: (taskId: string) => void;
26+
}
27+
28+
const useTitleAttachmentStore = create<TitleAttachmentStore>((set, get) => ({
29+
byTaskId: {},
30+
set: (taskId, filePaths) =>
31+
set((state) => ({
32+
byTaskId: { ...state.byTaskId, [taskId]: filePaths },
33+
})),
34+
get: (taskId) => get().byTaskId[taskId],
35+
clear: (taskId) =>
36+
set((state) => {
37+
if (!(taskId in state.byTaskId)) return state;
38+
const { [taskId]: _removed, ...rest } = state.byTaskId;
39+
return { byTaskId: rest };
40+
}),
41+
}));
42+
43+
export const titleAttachmentStoreApi = {
44+
set: (taskId: string, filePaths: string[]) =>
45+
useTitleAttachmentStore.getState().set(taskId, filePaths),
46+
get: (taskId: string) => useTitleAttachmentStore.getState().get(taskId),
47+
clear: (taskId: string) => useTitleAttachmentStore.getState().clear(taskId),
48+
};

0 commit comments

Comments
 (0)