Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/core/src/sessions/titleGeneratorService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,38 @@ describe("enrichDescriptionWithFileContent", () => {
},
);

it.each([
{
label: "cloud description summary",
description: "Attached files: pasted-text.txt",
},
{
label: "numbered prompt list item",
description: "1. [Attached files: pasted-text.txt]",
},
])(
"reads explicit file paths for attachment-only prompt -- $label",
async ({ description }) => {
readAbsoluteFile.mockResolvedValue("Refactor the auth flow and add tests");
const result = await makeService().enrichDescriptionWithFileContent(
description,
["/tmp/clip/pasted-text.txt"],
);
expect(result).toBe("Refactor the auth flow and add tests");
expect(readAbsoluteFile).toHaveBeenCalledWith("/tmp/clip/pasted-text.txt");
},
);

it("ignores explicit file paths when the prompt has real typed text", async () => {
const description = "Fix the login bug\n\nAttached files: pasted-text.txt";
const result = await makeService().enrichDescriptionWithFileContent(
description,
["/tmp/clip/pasted-text.txt"],
);
expect(result).toBe(description);
expect(readAbsoluteFile).not.toHaveBeenCalled();
});

it("truncates content longer than 500 chars", async () => {
const longContent = "x".repeat(600);
readAbsoluteFile.mockResolvedValue(longContent);
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/sessions/titleGeneratorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import {
type TitleGeneratorLogger,
} from "./titleGeneratorIdentifiers";

const ATTACHED_FILES_REGEX = /^\[?Attached files:.*]?$/gm;
// Matches the attachment-summary line we synthesize for prompts that carry no
// typed text — both the bare `Attached files: a.txt` description form and the
// `1. [Attached files: a.txt]` form produced by formatPromptsForTitleInput, so
// such a prompt is treated as "no real text" and we fall back to file content.
const ATTACHED_FILES_REGEX = /^(?:\d+\.\s*)?\[?Attached files:.*$/gm;
const PASTED_TEXT_SNIPPET_LIMIT = 500;

const SYSTEM_PROMPT = `You are a title and summary generator. Output using exactly this format:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const mockSetQueryData = vi.hoisted(() => vi.fn());
const mockUpdateSessionTaskTitle = vi.hoisted(() => vi.fn());
const mockPrompts = vi.hoisted(() => ({ value: [] as string[] }));
const mockSessionStoreSetters = vi.hoisted(() => ({ updateSession: vi.fn() }));
const mockTitleAttachmentPaths = vi.hoisted(() => ({ value: [] as string[] }));
const mockTitleAttachmentClear = vi.hoisted(() => vi.fn());

vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
Expand Down Expand Up @@ -64,6 +66,14 @@ vi.mock("@posthog/ui/shell/logger", () => ({
},
}));

vi.mock("@posthog/ui/shell/titleAttachmentStore", () => ({
titleAttachmentStoreApi: {
get: () => mockTitleAttachmentPaths.value,
set: vi.fn(),
clear: mockTitleAttachmentClear,
},
}));

vi.mock("@posthog/ui/features/sessions/sessionStore", () => {
const state = {
taskIdIndex: { "task-1": "run-1" },
Expand Down Expand Up @@ -108,6 +118,7 @@ describe("useChatTitleGenerator", () => {
vi.clearAllMocks();
mockIsAuthenticated.value = true;
mockPrompts.value = [];
mockTitleAttachmentPaths.value = [];
mockEnrichDescription.mockImplementation((desc: string) =>
Promise.resolve(desc),
);
Expand All @@ -130,7 +141,10 @@ describe("useChatTitleGenerator", () => {
renderHook(() => useChatTitleGenerator(createTask()));

await waitFor(() => {
expect(mockEnrichDescription).toHaveBeenCalledWith("Fix the login bug");
expect(mockEnrichDescription).toHaveBeenCalledWith(
"Fix the login bug",
[],
);
});
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, {
Expand Down Expand Up @@ -266,11 +280,44 @@ describe("useChatTitleGenerator", () => {
await waitFor(() => {
expect(mockEnrichDescription).toHaveBeenCalledWith(
'1. <file path="/tmp/code.ts" />',
[],
);
expect(mockGenerateTitle).toHaveBeenCalledWith("enriched content");
});
});

it("passes stashed local attachment paths and clears them after naming", async () => {
mockTitleAttachmentPaths.value = ["/tmp/clip/pasted-text.txt"];
mockEnrichDescription.mockResolvedValue("Refactor the auth flow");
mockGenerateTitle.mockResolvedValue({
title: "Refactor auth flow",
summary: "",
});
mockPrompts.value = ["[Attached files: pasted-text.txt]"];

renderHook(() =>
useChatTitleGenerator(
createTask({
title: "",
description: "Attached files: pasted-text.txt",
}),
),
);

await waitFor(() => {
expect(mockEnrichDescription).toHaveBeenCalledWith(
"1. [Attached files: pasted-text.txt]",
["/tmp/clip/pasted-text.txt"],
);
});
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith(TASK_ID, {
title: "Refactor auth flow",
});
});
expect(mockTitleAttachmentClear).toHaveBeenCalledWith(TASK_ID);
});

it("updates conversation summary when returned", async () => {
mockGenerateTitle.mockResolvedValue({
title: "Some title",
Expand Down
11 changes: 9 additions & 2 deletions packages/ui/src/features/sessions/hooks/useChatTitleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "@posthog/ui/features/sessions/sessionStore";
import { taskKeys } from "@posthog/ui/features/tasks/taskKeys";
import { logger } from "@posthog/ui/shell/logger";
import { titleAttachmentStoreApi } from "@posthog/ui/shell/titleAttachmentStore";
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";

Expand Down Expand Up @@ -95,10 +96,16 @@ export function useChatTitleGenerator(task: Task): void {

const run = async () => {
try {
const content =
await titleGenerator.enrichDescriptionWithFileContent(rawContent);
const attachmentPaths = titleAttachmentStoreApi.get(taskId) ?? [];
const content = await titleGenerator.enrichDescriptionWithFileContent(
rawContent,
attachmentPaths,
);
const result = await titleGenerator.generateTitleAndSummary(content);
if (result) {
// The seed is consumed once a title has been produced; drop it so the
// map doesn't grow across a long-lived session.
titleAttachmentStoreApi.clear(taskId);
Comment on lines 104 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stash not cleared when LLM returns null

titleAttachmentStoreApi.clear(taskId) is only called inside if (result), so when generateTitleAndSummary returns null (on error or empty output), the stash entry survives. In the shouldGenerateFromTaskDescription path, initialDescriptionHandled is unconditionally set to true in the finally block, which prevents any description-path retry. The stash therefore stays populated for the rest of the session without any mechanism to consume or evict it other than the next prompt-based generation or a reload. This is acknowledged as best-effort, but moving the clear call to the finally block (only for the description path, not if a retry on prompts is still wanted) would make the lifecycle explicit.

const { title, summary } = result;
const titleLocked = isAutoTitleLocked(
getCachedTask(queryClient, taskId) ?? task,
Expand Down
9 changes: 9 additions & 0 deletions packages/ui/src/features/task-detail/hooks/useTaskCreation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { toast } from "../../../primitives/toast";
import { track } from "../../../shell/analytics";
import { logger } from "../../../shell/logger";
import { pendingTaskPromptStoreApi } from "../../../shell/pendingTaskPromptStore";
import { titleAttachmentStoreApi } from "../../../shell/titleAttachmentStore";
import { useAuthStateValue } from "../../auth/store";
import { assertCloudUsageAvailable } from "../../billing/preflightCloudUsage";
import { useUsageLimitStore } from "../../billing/usageLimitStore";
Expand Down Expand Up @@ -288,6 +289,14 @@ export function useTaskCreation({
input,
(output) => {
invalidateTasks(output.task);
// Stash the prompt's local attachment paths so the chat-title
// generator can read their contents when naming the task — needed
// for pasted-text prompts whose only signal is the file body, and
// especially for cloud tasks where the local path is otherwise lost
// once the file is uploaded as an artifact.
if (filePaths.length > 0) {
titleAttachmentStoreApi.set(output.task.id, filePaths);
}
if (signalReportId) {
clearTaskInputReportAssociation();
}
Expand Down
50 changes: 50 additions & 0 deletions packages/ui/src/shell/titleAttachmentStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { create } from "zustand";

/**
* Local attachment file paths captured at task-creation time, keyed by task id,
* so the chat-title generator can read their contents when naming a task.
*
* Why this exists: when a prompt is pasted as a text file (or otherwise sent as
* an attachment with no typed text), the title has to come from the file's
* contents. For local tasks the prompt event still carries the `<file .../>`
* path, so the generator can read it directly. For cloud tasks it cannot — the
* stored description is reduced to `Attached files: <name>` and the echoed
* prompt event points at the remote sandbox path (e.g.
* `file:///workspace/.posthog/attachments/...`), which is not readable on the
* user's machine. The only place the original local path exists is at submit
* time, so we stash it here and hand it to the generator, which reads the file
* locally before it is cleaned up.
*
* Best-effort and in-memory: lost on reload, at which point the title falls back
* to the attachment summary.
*/
interface TitleAttachmentStore {
byTaskId: Record<string, string[]>;
set: (taskId: string, filePaths: string[]) => void;
get: (taskId: string) => string[] | undefined;
clear: (taskId: string) => void;
}

export const useTitleAttachmentStore = create<TitleAttachmentStore>(
(set, get) => ({
byTaskId: {},
set: (taskId, filePaths) =>
set((state) => ({
byTaskId: { ...state.byTaskId, [taskId]: filePaths },
})),
get: (taskId) => get().byTaskId[taskId],
clear: (taskId) =>
set((state) => {
if (!(taskId in state.byTaskId)) return state;
const { [taskId]: _removed, ...rest } = state.byTaskId;
return { byTaskId: rest };
}),
}),
);
Comment on lines +28 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unused React hook export

useTitleAttachmentStore is exported but never imported anywhere in the codebase (confirmed: only appears in its own file). The analogous usePendingTaskPromptStore in pendingTaskPromptStore.ts is at least wrapped by a usePendingTaskPrompt helper that consumes it; there is no equivalent usage here. Per the simplicity rule of no superfluous parts, this export can be dropped — titleAttachmentStoreApi is sufficient for all current call sites.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


export const titleAttachmentStoreApi = {
set: (taskId: string, filePaths: string[]) =>
useTitleAttachmentStore.getState().set(taskId, filePaths),
get: (taskId: string) => useTitleAttachmentStore.getState().get(taskId),
clear: (taskId: string) => useTitleAttachmentStore.getState().clear(taskId),
};
Loading