From 116c4f0a64c21dd9f9263e129c32b473b9cc6ff2 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 15:56:47 +0300 Subject: [PATCH 01/13] feat(agent): record commit artefacts after each signed-commit push After every successful git_signed_commit push the harness posts one `commit` artefact per pushed commit to every signal report the task is associated with, attributed via the X-PostHog-Task-Id header. Best-effort: reportCommitArtefacts never throws, so an artefact failure can't fail a commit that already landed. SignedCommitResult now carries the repository (owner/repo) so the hook doesn't re-derive it. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- .../local-tools/tools/signed-commit.test.ts | 1 + .../src/adapters/signed-commit-shared.ts | 16 +- packages/agent/src/posthog-api.ts | 29 ++++ .../agent/src/signed-commit-artefacts.test.ts | 145 ++++++++++++++++++ packages/agent/src/signed-commit-artefacts.ts | 111 ++++++++++++++ packages/git/src/signed-commit.ts | 8 +- 6 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 packages/agent/src/signed-commit-artefacts.test.ts create mode 100644 packages/agent/src/signed-commit-artefacts.ts diff --git a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts index 070f9f0284..2aa343d4b1 100644 --- a/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/signed-commit.test.ts @@ -22,6 +22,7 @@ describe("signed-commit tool handler", () => { createSignedCommit.mockReset(); createSignedCommit.mockResolvedValue({ branch: "posthog-code/feature", + repository: "x/y", commits: [ { sha: "deadbeef", url: "https://github.com/x/y/commit/deadbeef" }, ], diff --git a/packages/agent/src/adapters/signed-commit-shared.ts b/packages/agent/src/adapters/signed-commit-shared.ts index 7821255630..0ed2a38a45 100644 --- a/packages/agent/src/adapters/signed-commit-shared.ts +++ b/packages/agent/src/adapters/signed-commit-shared.ts @@ -9,6 +9,7 @@ import { type SignedRewriteInput, } from "@posthog/git/signed-commit"; import { z } from "zod"; +import { reportCommitArtefacts } from "../signed-commit-artefacts"; import { qualifiedLocalToolName } from "./local-tools/registry"; /** @@ -167,7 +168,20 @@ export function runSignedCommitTool( ): Promise { return runSignedTool( SIGNED_COMMIT_TOOL_NAME, - createSignedCommit, + async (c, a: SignedCommitInput) => { + const result = await createSignedCommit(c, a); + // The "commit hook": every pushed commit becomes a `commit` artefact on the signal + // reports this task is associated with. Best-effort and awaited inside the tool's + // try/catch-free success path — reportCommitArtefacts never throws, so a failed + // artefact post can't fail a commit that already landed. git_signed_rewrite is + // intentionally not hooked (it republishes existing history). + await reportCommitArtefacts({ + taskId: c.taskId, + result, + message: a.message, + }); + return result; + }, (r) => `Created ${r.commits.length} signed commit(s) on ${r.branch}`, ctx, args, diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index fb0c161d1a..fc8b9ccd5a 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -245,6 +245,35 @@ export class PostHogAPIClient { return manifest.slice(-artifacts.length); } + /** Signal reports the given task is associated with (via report task associations). */ + async getSignalReportIdsForTask(taskId: string): Promise { + const teamId = this.getTeamId(); + const response = await this.apiRequest<{ results?: { id: string }[] }>( + `/api/projects/${teamId}/signals/reports/?task_id=${encodeURIComponent(taskId)}&limit=100`, + ); + return (response.results ?? []).map((r) => r.id); + } + + /** + * Append a log artefact to a signal report, attributed to `taskId` via the + * `X-PostHog-Task-Id` header (the server validates it against the token's team). + */ + async createSignalReportArtefact( + reportId: string, + taskId: string, + body: { artefact_type: string; content: Record }, + ): Promise { + const teamId = this.getTeamId(); + await this.apiRequest( + `/api/projects/${teamId}/signals/reports/${reportId}/artefacts/`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "X-PostHog-Task-Id": taskId }, + }, + ); + } + /** * Download artifact content by storage path * Streams the file through the PostHog backend so the sandbox does not need diff --git a/packages/agent/src/signed-commit-artefacts.test.ts b/packages/agent/src/signed-commit-artefacts.test.ts new file mode 100644 index 0000000000..e8a0f1942e --- /dev/null +++ b/packages/agent/src/signed-commit-artefacts.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { reportCommitArtefacts } from "./signed-commit-artefacts"; + +const ENV = { + POSTHOG_API_URL: "https://us.posthog.com", + POSTHOG_PERSONAL_API_KEY: "pha_test", + POSTHOG_PROJECT_ID: "7", +}; + +// Point the env-file read at a path that never exists so only `env` is used. +const NO_ENV_FILE = "/nonexistent/agent-env"; + +const RESULT = { + branch: "posthog-code/fix-foo", + repository: "posthog/posthog", + commits: [ + { sha: "aaa111", url: "https://github.com/posthog/posthog/commit/aaa111" }, + { sha: "bbb222", url: "https://github.com/posthog/posthog/commit/bbb222" }, + ], +}; + +describe("reportCommitArtefacts", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + it("posts one commit artefact per commit per associated report, attributed via header", async () => { + fetchMock.mockImplementation(async (url: string | URL) => { + if (String(url).includes("/signals/reports/?")) { + return jsonResponse({ + results: [{ id: "report-1" }, { id: "report-2" }], + }); + } + return jsonResponse({ id: "artefact" }); + }); + + await reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }); + + const lookupCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("/signals/reports/?task_id=task-1"), + ); + expect(lookupCalls).toHaveLength(1); + + const postCalls = fetchMock.mock.calls.filter(([url]) => + String(url).includes("/artefacts/"), + ); + // 2 commits × 2 reports. + expect(postCalls).toHaveLength(4); + for (const [url, init] of postCalls) { + expect(String(url)).toMatch( + /\/api\/projects\/7\/signals\/reports\/report-[12]\/artefacts\/$/, + ); + const headers = new Headers((init as RequestInit).headers); + expect(headers.get("X-PostHog-Task-Id")).toBe("task-1"); + const body = JSON.parse(String((init as RequestInit).body)); + expect(body.artefact_type).toBe("commit"); + expect(body.content.repository).toBe("posthog/posthog"); + expect(body.content.branch).toBe("posthog-code/fix-foo"); + expect(["aaa111", "bbb222"]).toContain(body.content.commit_sha); + expect(body.content.message).toBe("fix: foo"); + } + }); + + it("is a no-op without a task id", async () => { + await reportCommitArtefacts({ + taskId: undefined, + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("is a no-op without sandbox PostHog credentials", async () => { + await reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: {}, + envFilePath: NO_ENV_FILE, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("never throws when the report lookup fails", async () => { + fetchMock.mockRejectedValue(new Error("network down")); + await expect( + reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }), + ).resolves.toBeUndefined(); + }); + + it("keeps posting remaining artefacts when one post fails", async () => { + let postCount = 0; + fetchMock.mockImplementation(async (url: string | URL) => { + if (String(url).includes("/signals/reports/?")) { + return jsonResponse({ results: [{ id: "report-1" }] }); + } + postCount += 1; + if (postCount === 1) { + return new Response("{}", { status: 500 }); + } + return jsonResponse({ id: "artefact" }); + }); + + await reportCommitArtefacts({ + taskId: "task-1", + result: RESULT, + message: "fix: foo", + env: ENV, + envFilePath: NO_ENV_FILE, + }); + + // Both commits attempted despite the first failing. + expect(postCount).toBe(2); + }); +}); diff --git a/packages/agent/src/signed-commit-artefacts.ts b/packages/agent/src/signed-commit-artefacts.ts new file mode 100644 index 0000000000..8d4e349e5d --- /dev/null +++ b/packages/agent/src/signed-commit-artefacts.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "node:fs"; +import type { SignedCommitResult } from "@posthog/git/signed-commit"; +import { PostHogAPIClient } from "./posthog-api"; +import { SANDBOX_ENV_FILE } from "./utils/github-token"; + +/** + * Best-effort "commit hook": after a successful signed-commit push, record one `commit` + * artefact per pushed commit on every signal report the task is associated with, so the + * report's work log shows exactly what landed. Attribution is deterministic — the artefact + * endpoint reads the `X-PostHog-Task-Id` header, never the model. + * + * Credentials come from the sandbox environment (`POSTHOG_API_URL` / + * `POSTHOG_PERSONAL_API_KEY` / `POSTHOG_PROJECT_ID`), preferring the live agentsh env file + * for the key so a mid-session token refresh is picked up — the same pattern as + * `resolveGithubToken`. Works identically from the Claude in-process server and the Codex + * stdio child (both inherit the sandbox env). Never throws: a failed artefact post must not + * fail the commit that already landed. + */ + +interface SandboxPosthogApi { + apiUrl: string; + apiKey: string; + projectId: number; +} + +function readSandboxEnvFile(envFilePath: string): Record { + try { + const raw = readFileSync(envFilePath, "utf8"); + const env: Record = {}; + for (const entry of raw.split("\0")) { + const eq = entry.indexOf("="); + if (eq > 0) { + env[entry.slice(0, eq)] = entry.slice(eq + 1); + } + } + return env; + } catch { + // No env file (local/desktop or test) — fall back to the process env only. + return {}; + } +} + +export function resolveSandboxPosthogApi( + env: Record = process.env, + envFilePath: string = SANDBOX_ENV_FILE, +): SandboxPosthogApi | undefined { + const fileEnv = readSandboxEnvFile(envFilePath); + const apiUrl = fileEnv.POSTHOG_API_URL ?? env.POSTHOG_API_URL; + const apiKey = + fileEnv.POSTHOG_PERSONAL_API_KEY ?? env.POSTHOG_PERSONAL_API_KEY; + const projectId = Number( + fileEnv.POSTHOG_PROJECT_ID ?? env.POSTHOG_PROJECT_ID, + ); + if (!apiUrl || !apiKey || !Number.isFinite(projectId) || projectId <= 0) { + return undefined; + } + return { apiUrl, apiKey, projectId }; +} + +export async function reportCommitArtefacts(opts: { + taskId: string | undefined; + result: SignedCommitResult; + /** Commit headline — the same for every chunk of a split payload. */ + message: string; + env?: Record; + envFilePath?: string; +}): Promise { + const { taskId, result, message } = opts; + if (!taskId) { + return; // Local/desktop run — no task to attribute or associate through. + } + try { + const api = resolveSandboxPosthogApi(opts.env, opts.envFilePath); + if (!api) { + return; // No sandbox PostHog credentials — nothing to report to. + } + const client = new PostHogAPIClient({ + apiUrl: api.apiUrl, + projectId: api.projectId, + getApiKey: () => api.apiKey, + }); + const reportIds = await client.getSignalReportIdsForTask(taskId); + for (const reportId of reportIds) { + for (const commit of result.commits) { + try { + await client.createSignalReportArtefact(reportId, taskId, { + artefact_type: "commit", + content: { + repository: result.repository, + branch: result.branch, + commit_sha: commit.sha, + message, + }, + }); + } catch (err) { + warn( + `failed to record commit ${commit.sha} on report ${reportId}: ${err}`, + ); + } + } + } + } catch (err) { + warn(`failed to record commit artefacts: ${err}`); + } +} + +// stderr directly (not console) — this also runs inside the Codex stdio MCP child, +// where stdout is the protocol channel. +function warn(message: string): void { + process.stderr.write(`[signed-commit-artefacts] ${message}\n`); +} diff --git a/packages/git/src/signed-commit.ts b/packages/git/src/signed-commit.ts index 820d20ed15..bf6fba7e20 100644 --- a/packages/git/src/signed-commit.ts +++ b/packages/git/src/signed-commit.ts @@ -55,6 +55,8 @@ export interface SignedCommitInput { export interface SignedCommitResult { branch: string; + /** Repository the commits were pushed to, as `owner/repo` (from the origin remote). */ + repository: string; /** One entry per chunk; >1 only when the payload was split. */ commits: { sha: string; url: string }[]; } @@ -822,7 +824,7 @@ export async function createSignedCommit( (await runGit(["diff", "--cached", "--quiet", "HEAD"], ctx.cwd)) .exitCode !== 0; if (hasStagedChanges) { - return { branch, commits: [] }; + return { branch, repository: repo, commits: [] }; } throw new Error( "No staged changes to commit. Stage files with `git add` first (or pass `paths`).", @@ -846,7 +848,7 @@ export async function createSignedCommit( ); await syncLocalCheckout(ctx, branch, newTip); - return { branch, commits }; + return { branch, repository: repo, commits }; } /** Splits a raw commit message into a headline and the remaining body */ @@ -990,7 +992,7 @@ export async function createSignedRewrite( } await forceUpdateRef(ctx, repo, branch, expectedHeadOid); await syncLocalCheckout(ctx, branch, expectedHeadOid); - return { branch, commits }; + return { branch, repository: repo, commits }; } finally { // The history is already published via the ref move; the scratch ref is just // bookkeeping, so a delete failure is non-fatal. From 89c2963069f5d1682c522e6a8e863763633f7340 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 16:09:14 +0300 Subject: [PATCH 02/13] feat(inbox): render the signal report artefact log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Activity section below the report summary rendering every artefact chronologically with a tailored body per type: syntax-highlighted code references and line references, unified-diff coloring for code diffs, commits with a lazily-fetched commit-vs-parent diff (new getCommitDiff endpoint), task runs that expand to the full conversation log, notes, judgments, findings, reviewers, and dismissals — plus a dev-only raw-JSON inspector. Artefacts are an append-only history now, so the status selectors (reviewers, judgments, findings) become explicitly latest-wins instead of relying on response ordering, and the reviewers edit optimistically appends a synthetic latest row mirroring the server's append semantics. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- packages/api-client/src/posthog-client.ts | 253 +++++++++++- packages/core/src/inbox/reportArtefacts.ts | 59 ++- packages/shared/src/domain-types.ts | 165 ++++++-- .../inbox/components/InboxDetailFrame.tsx | 6 +- .../inbox/components/ReportDetail.tsx | 23 ++ .../components/detail/ArtefactCommit.tsx | 95 +++++ .../components/detail/ArtefactLogList.tsx | 379 ++++++++++++++++++ .../components/detail/ArtefactTaskRun.tsx | 94 +++++ .../inbox/components/detail/DiffBlock.tsx | 27 ++ .../inbox/hooks/useInboxReports.test.tsx | 19 +- .../features/inbox/hooks/useInboxReports.ts | 32 +- 11 files changed, 1079 insertions(+), 73 deletions(-) create mode 100644 packages/ui/src/features/inbox/components/detail/ArtefactCommit.tsx create mode 100644 packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx create mode 100644 packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx create mode 100644 packages/ui/src/features/inbox/components/detail/DiffBlock.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e5762e38af..78fd1cf609 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -16,7 +16,13 @@ import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, AvailableSuggestedReviewersResponse, + CodeDiffArtefact, + CodeReferenceArtefact, + CommitArtefact, + CommitDiffResponse, DismissalArtefact, + LineReferenceArtefact, + NoteArtefact, PriorityJudgmentArtefact, RepoSelectionArtefact, SandboxEnvironment, @@ -31,7 +37,6 @@ import type { SignalReportsQueryParams, SignalReportsResponse, SignalReportTask, - SignalReportTaskRelationship, SignalTeamConfig, SignalUserAutonomyConfig, SlackChannelsQueryParams, @@ -40,6 +45,7 @@ import type { SuggestedReviewerWriteEntry, Task, TaskRun, + TaskRunArtefact, } from "@posthog/shared/domain-types"; import { buildApiFetcher } from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; @@ -427,6 +433,25 @@ function optionalString(value: unknown): string | null { return typeof value === "string" ? value : null; } +/** Unwrap the shared fetcher's `Failed request: [] ` into the endpoint's clean message. */ +function extractRequestErrorMessage(error: unknown, fallback: string): string { + const raw = error instanceof Error ? error.message : String(error); + const match = raw.match(/^Failed request: \[(\d+)\] (.*)$/s); + if (!match) { + return fallback; + } + try { + const body = JSON.parse(match[2]) as { error?: unknown; detail?: unknown }; + const message = body.error ?? body.detail; + if (typeof message === "string" && message.trim()) { + return message; + } + } catch { + // Non-JSON body — fall through to the status-based fallback. + } + return `${fallback} (HTTP ${match[1]})`; +} + type AnyArtefact = | SignalReportArtefact | PriorityJudgmentArtefact @@ -434,7 +459,13 @@ type AnyArtefact = | SignalFindingArtefact | RepoSelectionArtefact | SuggestedReviewersArtefact - | DismissalArtefact; + | DismissalArtefact + | CodeReferenceArtefact + | CodeDiffArtefact + | LineReferenceArtefact + | CommitArtefact + | TaskRunArtefact + | NoteArtefact; const DISMISSAL_REASONS = new Set( DISMISSAL_REASON_OPTIONS.map((o) => o.value), @@ -596,6 +627,164 @@ function normalizeDismissalArtefact( }; } +// ── Log artefact normalizers ────────────────────────────────────────────── +// The backend stores log-artefact content as a JSON object (not the string-or- +// session_id shape the generic fallback expects), so each type needs an explicit +// normalizer — otherwise it falls through and gets dropped. + +function logArtefactBase(value: Record): { + created_at: string; + updated_at: string | null; +} { + return { + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + updated_at: optionalString(value.updated_at), + }; +} + +function normalizeCodeReferenceArtefact( + value: Record, +): CodeReferenceArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const file_path = optionalString(c.file_path); + if (!file_path) return null; + + return { + id, + type: "code_reference", + ...logArtefactBase(value), + content: { + file_path, + start_line: typeof c.start_line === "number" ? c.start_line : 0, + end_line: typeof c.end_line === "number" ? c.end_line : 0, + contents: optionalString(c.contents) ?? "", + relevance_note: optionalString(c.relevance_note) ?? "", + }, + }; +} + +function normalizeCodeDiffArtefact( + value: Record, +): CodeDiffArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const file_path = optionalString(c.file_path); + if (!file_path) return null; + + return { + id, + type: "code_diff", + ...logArtefactBase(value), + content: { + file_path, + diff: optionalString(c.diff) ?? "", + relevance_note: optionalString(c.relevance_note) ?? "", + }, + }; +} + +function normalizeLineReferenceArtefact( + value: Record, +): LineReferenceArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const file_path = optionalString(c.file_path); + if (!file_path) return null; + + return { + id, + type: "line_reference", + ...logArtefactBase(value), + content: { + file_path, + line: typeof c.line === "number" ? c.line : 0, + note: optionalString(c.note) ?? "", + contents: optionalString(c.contents), + }, + }; +} + +function normalizeCommitArtefact( + value: Record, +): CommitArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const repository = optionalString(c.repository); + const branch = optionalString(c.branch); + const commit_sha = optionalString(c.commit_sha); + if (!repository || !branch || !commit_sha) return null; + + return { + id, + type: "commit", + ...logArtefactBase(value), + task_id: optionalString(value.task_id), + content: { + repository, + branch, + commit_sha, + message: optionalString(c.message) ?? "", + note: optionalString(c.note), + }, + }; +} + +function normalizeTaskRunArtefact( + value: Record, +): TaskRunArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const task_id = optionalString(c.task_id); + if (!task_id) return null; + const product = optionalString(c.product); + const type = optionalString(c.type); + if (!product || !type) return null; + + return { + id, + type: "task_run", + ...logArtefactBase(value), + content: { + task_id, + run_id: optionalString(c.run_id), + product, + type, + }, + }; +} + +function normalizeNoteArtefact( + value: Record, +): NoteArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + const c = isObjectRecord(value.content) ? value.content : null; + if (!c) return null; + const note = optionalString(c.note); + if (!note) return null; + + return { + id, + type: "note", + ...logArtefactBase(value), + content: { + note, + author: optionalString(c.author), + }, + }; +} + function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { if (!isObjectRecord(value)) { return null; @@ -617,6 +806,24 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { if (dispatchType === "dismissal") { return normalizeDismissalArtefact(value); } + if (dispatchType === "code_reference") { + return normalizeCodeReferenceArtefact(value); + } + if (dispatchType === "code_diff") { + return normalizeCodeDiffArtefact(value); + } + if (dispatchType === "line_reference") { + return normalizeLineReferenceArtefact(value); + } + if (dispatchType === "commit") { + return normalizeCommitArtefact(value); + } + if (dispatchType === "task_run") { + return normalizeTaskRunArtefact(value); + } + if (dispatchType === "note") { + return normalizeNoteArtefact(value); + } const id = optionalString(value.id); if (!id) { @@ -1553,8 +1760,6 @@ export class PostHogAPIClient { > & { github_integration?: number | null; github_user_integration?: string | null; - /** POST-only: `SignalReportTask.relationship` to create when linking to `signal_report`. */ - signal_report_task_relationship?: SignalReportTaskRelationship; }, ) { const teamId = await this.getTeamId(); @@ -2686,6 +2891,32 @@ export class PostHogAPIClient { } } + async getCommitDiff( + reportId: string, + artefactId: string, + ): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/signals/reports/${reportId}/artefacts/${artefactId}/diff/`; + const url = new URL(`${this.api.baseUrl}${path}`); + + // The shared fetcher throws `Failed request: [] ` for any non-2xx, so + // unwrap that into the endpoint's clean `error` message rather than surfacing the raw string. + let response: Response; + try { + response = await this.api.fetcher.fetch({ method: "get", url, path }); + } catch (error) { + throw new Error( + extractRequestErrorMessage(error, "Couldn\u2019t load the diff."), + ); + } + + const data = (await response.json()) as Partial; + return { + diff: typeof data.diff === "string" ? data.diff : "", + truncated: data.truncated === true, + }; + } + async updateSignalReportState( reportId: string, input: @@ -2727,6 +2958,12 @@ export class PostHogAPIClient { return (await response.json()) as SignalReport; } + /** + * Edit a report's suggested reviewers. The server appends a new `suggested_reviewers` status + * artefact (latest-wins), canonicalizes each entry to a lowercase `github_login`, and carries + * `relevant_commits` / `github_name` forward from the current reviewers for surviving logins. + * Returns the newly-appended artefact (a fresh id), not the one addressed by `artefactId`. + */ async updateSignalReportArtefact( reportId: string, artefactId: string, @@ -2813,17 +3050,11 @@ export class PostHogAPIClient { }; } - async getSignalReportTasks( - reportId: string, - options?: { relationship?: SignalReportTask["relationship"] }, - ): Promise { + async getSignalReportTasks(reportId: string): Promise { const teamId = await this.getTeamId(); const url = new URL( `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/tasks/`, ); - if (options?.relationship) { - url.searchParams.set("relationship", options.relationship); - } const path = `/api/projects/${teamId}/signals/reports/${reportId}/tasks/`; const response = await this.api.fetcher.fetch({ diff --git a/packages/core/src/inbox/reportArtefacts.ts b/packages/core/src/inbox/reportArtefacts.ts index 9373cca87a..d03fd595c2 100644 --- a/packages/core/src/inbox/reportArtefacts.ts +++ b/packages/core/src/inbox/reportArtefacts.ts @@ -10,12 +10,31 @@ import type { type ReportArtefact = SignalReportArtefactsResponse["results"][number]; +// Artefacts are an append-only history: status types (judgments, reviewers) are +// latest-wins, and `signal_finding` is keyed by signal_id with the latest version +// per signal winning. ISO-8601 `created_at` strings compare lexicographically in +// chronological order, so selection is order-independent rather than relying on +// the API's `-created_at` response ordering. +function latestOfType( + artefacts: ReportArtefact[], + type: T["type"], +): T | null { + let latest: T | null = null; + for (const a of artefacts) { + if (a.type === type && (!latest || a.created_at > latest.created_at)) { + latest = a as T; + } + } + return latest; +} + export function selectSuggestedReviewers( artefacts: ReportArtefact[], meUuid?: string, ): SuggestedReviewer[] { - const artefact = artefacts.find( - (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", + const artefact = latestOfType( + artefacts, + "suggested_reviewers", ); const reviewers = artefact?.content ?? []; if (!meUuid) return reviewers; @@ -27,34 +46,38 @@ export function selectSuggestedReviewers( export function buildSignalFindingMap( artefacts: ReportArtefact[], ): Map { - const map = new Map(); + const latestBySignal = new Map(); for (const a of artefacts) { - if (a.type === "signal_finding") { - const finding = a as SignalFindingArtefact; - map.set(finding.content.signal_id, finding.content); + if (a.type !== "signal_finding") continue; + const finding = a as SignalFindingArtefact; + const existing = latestBySignal.get(finding.content.signal_id); + if (!existing || finding.created_at > existing.created_at) { + latestBySignal.set(finding.content.signal_id, finding); } } + const map = new Map(); + for (const [signalId, finding] of latestBySignal) { + map.set(signalId, finding.content); + } return map; } export function selectActionabilityJudgment( artefacts: ReportArtefact[], ): ActionabilityJudgmentContent | null { - for (const a of artefacts) { - if (a.type === "actionability_judgment") { - return (a as ActionabilityJudgmentArtefact).content; - } - } - return null; + return ( + latestOfType( + artefacts, + "actionability_judgment", + )?.content ?? null + ); } export function selectPriorityExplanation( artefacts: ReportArtefact[], ): string | null { - for (const a of artefacts) { - if (a.type === "priority_judgment") { - return (a as PriorityJudgmentArtefact).content.explanation || null; - } - } - return null; + return ( + latestOfType(artefacts, "priority_judgment") + ?.content.explanation || null + ); } diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index a616fa6346..36c37af189 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -394,6 +394,125 @@ export interface DismissalContent { user_uuid: string | null; } +// ── Log artefacts ──────────────────────────────────────────────────────────── +// Append-but-deletable "work log" entries that accumulate on a report. Distinct +// from the status artefacts above (judgments, reviewers) which are latest-wins. +// Content shapes mirror products/signals/backend/artefact_schemas.py. + +/** Artefact with `type: "code_reference"` — a contiguous span of source lines. */ +export interface CodeReferenceArtefact { + id: string; + type: "code_reference"; + content: CodeReferenceContent; + created_at: string; + updated_at?: string | null; +} + +export interface CodeReferenceContent { + file_path: string; + start_line: number; + end_line: number; + contents: string; + relevance_note: string; +} + +/** Artefact with `type: "code_diff"` — a unified diff for a single file. */ +export interface CodeDiffArtefact { + id: string; + type: "code_diff"; + content: CodeDiffContent; + created_at: string; + updated_at?: string | null; +} + +export interface CodeDiffContent { + file_path: string; + diff: string; + relevance_note: string; +} + +/** Artefact with `type: "line_reference"` — a single source line callout (a point). */ +export interface LineReferenceArtefact { + id: string; + type: "line_reference"; + content: LineReferenceContent; + created_at: string; + updated_at?: string | null; +} + +export interface LineReferenceContent { + file_path: string; + line: number; + note: string; + /** The exact source text of the referenced line, if available. */ + contents?: string | null; +} + +/** Artefact with `type: "commit"` — one commit pushed in relation to the report. */ +export interface CommitArtefact { + id: string; + type: "commit"; + content: CommitContent; + created_at: string; + updated_at?: string | null; + /** Task the artefact is attributed to (the agent session that pushed it), when known. */ + task_id?: string | null; +} + +export interface CommitContent { + repository: string; + branch: string; + commit_sha: string; + message: string; + note?: string | null; +} + +/** Artefact with `type: "task_run"` — a reference to a `tasks.Task` run for the report. */ +export interface TaskRunArtefact { + id: string; + type: "task_run"; + content: TaskRunArtefactContent; + created_at: string; + updated_at?: string | null; +} + +export interface TaskRunArtefactContent { + task_id: string; + run_id?: string | null; + /** + * Product that ran the task — `signals` for the built-in pipeline, or a custom agent's + * product identifier (mirrors backend TaskRunArtefact). + */ + product: string; + /** + * Task type within the product — e.g. `research` / `implementation` / `repo_selection` for the + * signals pipeline, or a custom agent's type identifier. + */ + type: string; +} + +/** Artefact with `type: "note"` — a free-form note authored by an agent or by code. */ +export interface NoteArtefact { + id: string; + type: "note"; + content: NoteContent; + created_at: string; + updated_at?: string | null; +} + +export interface NoteContent { + note: string; + author?: string | null; +} + +/** Response from the `commit` artefact diff endpoint — the commit rendered against its parent. */ +export interface CommitDiffResponse { + /** Unified diff (patch) text introduced by the commit. */ + diff: string; + /** True when the diff was too large to return in full and has been truncated. */ + truncated: boolean; +} + export interface SuggestedReviewerCommit { sha: string; url: string; @@ -468,16 +587,24 @@ export interface SignalReportSignalsResponse { signals: Signal[]; } +/** Any artefact returned by the report `artefacts/` endpoint, discriminated on `type`. */ +export type AnySignalReportArtefact = + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact + | RepoSelectionArtefact + | SuggestedReviewersArtefact + | DismissalArtefact + | CodeReferenceArtefact + | CodeDiffArtefact + | LineReferenceArtefact + | CommitArtefact + | TaskRunArtefact + | NoteArtefact; + export interface SignalReportArtefactsResponse { - results: ( - | SignalReportArtefact - | PriorityJudgmentArtefact - | ActionabilityJudgmentArtefact - | SignalFindingArtefact - | RepoSelectionArtefact - | SuggestedReviewersArtefact - | DismissalArtefact - )[]; + results: AnySignalReportArtefact[]; count: number; unavailableReason?: | "forbidden" @@ -507,23 +634,13 @@ export interface SignalReportsQueryParams { priority?: string; } -/** Values match `SignalReportTask.Relationship` on the PostHog API. */ -export const SIGNAL_REPORT_TASK_RELATIONSHIPS = [ - "repo_selection", - "research", - "implementation", -] as const; - -export type SignalReportTaskRelationship = - (typeof SIGNAL_REPORT_TASK_RELATIONSHIPS)[number]; - -/** Inbox / cloud PR tasks must use this when creating the `SignalReportTask` link. */ -export const SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP: SignalReportTaskRelationship = - "implementation"; - +/** + * An unlabelled task↔report association. A task's purpose (research / implementation / …) + * is derived from the report's `task_run` artefacts, not stored on the link. + */ export interface SignalReportTask { id: string; - relationship: SignalReportTaskRelationship; + report_id: string; task_id: string; created_at: string; } diff --git a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx index ab629a6cba..0211799d37 100644 --- a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx +++ b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx @@ -49,6 +49,8 @@ interface InboxDetailFrameProps { Icon: ComponentType; title: string; } | null; + /** Optional section(s) rendered below the summary in the left column (e.g. the activity log). */ + belowSummary?: ReactNode; /** Sections rendered alongside the summary (Tasks, Suggested reviewers, …). */ children?: ReactNode; } @@ -72,6 +74,7 @@ export function InboxDetailFrame({ primaryAction, summarySection, evidenceSection, + belowSummary, children, }: InboxDetailFrameProps) { const { data: signalsResp } = useInboxReportSignals(report.id); @@ -167,7 +170,7 @@ export function InboxDetailFrame({ */}
-
+
+ {belowSummary}
diff --git a/packages/ui/src/features/inbox/components/ReportDetail.tsx b/packages/ui/src/features/inbox/components/ReportDetail.tsx index 1d10a13b48..c6ab5fac37 100644 --- a/packages/ui/src/features/inbox/components/ReportDetail.tsx +++ b/packages/ui/src/features/inbox/components/ReportDetail.tsx @@ -1,15 +1,20 @@ import { + ClockCounterClockwiseIcon, CopyIcon, FileTextIcon, MagnifyingGlassIcon, } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; +import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; +import { ArtefactLogList } from "@posthog/ui/features/inbox/components/detail/ArtefactLogList"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; import { ReportDetailActions } from "@posthog/ui/features/inbox/components/ReportDetailActions"; import { ReportTasksSection } from "@posthog/ui/features/inbox/components/ReportTasksSection"; import { SuggestedReviewersSection } from "@posthog/ui/features/inbox/components/SuggestedReviewersSection"; +import { useInboxReportArtefacts } from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { Text } from "@radix-ui/themes"; import { toast } from "sonner"; interface ReportDetailProps { @@ -35,6 +40,9 @@ export function ReportDetail({ } function ReportDetailContent({ report }: { report: SignalReport }) { + const { data: artefactsResp } = useInboxReportArtefacts(report.id); + const artefacts = artefactsResp?.results ?? []; + const handleCopyLink = () => { const url = `${window.location.origin}/code/inbox/reports/${report.id}`; navigator.clipboard @@ -65,6 +73,21 @@ function ReportDetailContent({ report }: { report: SignalReport }) { } summarySection={{ Icon: FileTextIcon, title: "Summary" }} evidenceSection={{ Icon: MagnifyingGlassIcon, title: "Evidence" }} + belowSummary={ + artefacts.length > 0 ? ( + + {artefacts.length} entr{artefacts.length === 1 ? "y" : "ies"} + + } + > + + + ) : null + } > diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactCommit.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactCommit.tsx new file mode 100644 index 0000000000..ff924493b3 --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ArtefactCommit.tsx @@ -0,0 +1,95 @@ +import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; +import { inboxReportKeys } from "@posthog/core/inbox/inboxQuery"; +import type { CommitContent } from "@posthog/shared/types"; +import { DiffBlock } from "@posthog/ui/features/inbox/components/detail/DiffBlock"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { Box, Flex, Spinner, Text } from "@radix-ui/themes"; +import { useState } from "react"; + +/** + * Renders a `commit` artefact: commit metadata plus a collapsible diff of the commit against + * its parent, fetched lazily (on first expand) from the team's GitHub integration. + */ +export function ArtefactCommit({ + reportId, + artefactId, + content, +}: { + reportId: string; + artefactId: string; + content: CommitContent; +}) { + const [expanded, setExpanded] = useState(false); + + const diffQuery = useAuthenticatedQuery( + [...inboxReportKeys.artefacts(reportId), artefactId, "diff"], + (client) => client.getCommitDiff(reportId, artefactId), + // Only fetch once expanded; a commit's diff is immutable. + { enabled: expanded, staleTime: 5 * 60_000, retry: false }, + ); + + return ( + + + {content.message} + + + {content.commit_sha.slice(0, 12)} · {content.repository}@ + {content.branch} + + {content.note?.trim() ? ( + + {content.note} + + ) : null} + + + + {expanded ? ( + + {diffQuery.isLoading ? ( + + + Fetching diff… + + ) : diffQuery.isError ? ( + + {diffQuery.error instanceof Error + ? diffQuery.error.message + : "Couldn’t load the diff."} + + ) : diffQuery.data?.diff.trim() ? ( + <> + + {diffQuery.data.truncated ? ( + + Diff truncated — too large to display in full. + + ) : null} + + ) : ( + + No changes recorded for this commit. + + )} + + ) : null} + + ); +} diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx new file mode 100644 index 0000000000..caa8fde8ec --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx @@ -0,0 +1,379 @@ +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import type { + ActionabilityJudgmentContent, + AnySignalReportArtefact, + CodeDiffContent, + CodeReferenceContent, + CommitContent, + DismissalContent, + LineReferenceContent, + NoteContent, + PriorityJudgmentContent, + SignalFindingContent, + SignalReportArtefactContent, + SuggestedReviewer, + TaskRunArtefactContent, +} from "@posthog/shared/types"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { ArtefactCommit } from "@posthog/ui/features/inbox/components/detail/ArtefactCommit"; +import { ArtefactTaskRun } from "@posthog/ui/features/inbox/components/detail/ArtefactTaskRun"; +import { DiffBlock } from "@posthog/ui/features/inbox/components/detail/DiffBlock"; +import { SignalReportActionabilityBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportActionabilityBadge"; +import { SignalReportPriorityBadge } from "@posthog/ui/features/inbox/components/utils/SignalReportPriorityBadge"; +import { CodeBlock } from "@posthog/ui/primitives/CodeBlock"; +import { HighlightedCode } from "@posthog/ui/primitives/HighlightedCode"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { useState } from "react"; + +// A chronological log of every artefact on a report. Each known type renders a +// tailored body; unrecognized types fall back to a plain text preview (never raw +// JSON). Timeline chrome (rails, grouping) is a follow-up — this is the polished +// content layer. + +// Each entry is framed as a point-in-time action ("what happened"), since the log is an +// append-only history of changes — the current status lives at the top of the report. +const TYPE_LABELS: Record = { + code_reference: "Code referenced", + code_diff: "Diff proposed", + line_reference: "Line highlighted", + commit: "Commit pushed", + task_run: "Task run", + note: "Note added", + priority_judgment: "Priority assessed", + actionability_judgment: "Actionability assessed", + safety_judgment: "Safety assessed", + signal_finding: "Signal investigated", + suggested_reviewers: "Reviewers suggested", + repo_selection: "Repo selected", + dismissal: "Report dismissed", + video_segment: "Video segment", +}; + +function typeLabel(type: string): string { + return TYPE_LABELS[type] ?? type; +} + +function prettify(value: string): string { + const cleaned = value.replace(/[-_]/g, " ").trim(); + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +/** Map a file path to a syntax-highlight language key (extension-based). */ +function languageFromPath(filePath: string): string { + return filePath.split(".").pop()?.toLowerCase() ?? ""; +} + +// The generic `SignalReportArtefact` fallback carries `type: string`, so it stays +// in every narrowed branch and breaks discriminated-union narrowing — the runtime +// `type` dispatch is authoritative (content is set alongside type in the +// normalizers), so we read `content` through the matching content type. + +/** Short, monospace location shown next to the type label (file path / span). */ +function locationLabel(artefact: AnySignalReportArtefact): string | null { + switch (artefact.type) { + case "code_reference": { + const c = artefact.content as CodeReferenceContent; + return `${c.file_path}:${c.start_line}-${c.end_line}`; + } + case "code_diff": + return (artefact.content as CodeDiffContent).file_path; + case "line_reference": { + const c = artefact.content as LineReferenceContent; + return `${c.file_path}:${c.line}`; + } + default: + return null; + } +} + +function CodeRefBlock({ code, language }: { code: string; language: string }) { + return ( + + + + + + ); +} + +function RelevanceNote({ note }: { note: string }) { + if (!note.trim()) return null; + return {note}; +} + +function ReviewersBody({ reviewers }: { reviewers: SuggestedReviewer[] }) { + if (reviewers.length === 0) { + return ( + + No reviewers assigned. + + ); + } + return ( + + {reviewers.map((reviewer) => ( + + {reviewer.github_login ? ( + e.currentTarget.classList.add("loaded")} + /> + ) : null} + + {reviewer.user?.first_name ?? + reviewer.github_name ?? + reviewer.github_login} + + {reviewer.github_login ? ( + + @{reviewer.github_login} + + + ) : null} + + ))} + + ); +} + +function ArtefactBody({ + reportId, + artefact, +}: { + reportId: string; + artefact: AnySignalReportArtefact; +}) { + switch (artefact.type) { + case "code_reference": { + const c = artefact.content as CodeReferenceContent; + return ( + + + + + ); + } + case "code_diff": { + const c = artefact.content as CodeDiffContent; + return ( + + + + + + + ); + } + case "line_reference": { + const c = artefact.content as LineReferenceContent; + return ( + + + {c.contents ? ( + + ) : null} + + ); + } + case "commit": + return ( + + ); + case "task_run": + return ( + + ); + case "note": { + const c = artefact.content as NoteContent; + return ( + + + {c.author ? ( + + — {c.author} + + ) : null} + + ); + } + case "priority_judgment": { + const c = artefact.content as PriorityJudgmentContent; + return ( + + + {c.explanation ? : null} + + ); + } + case "actionability_judgment": { + const c = artefact.content as ActionabilityJudgmentContent; + return ( + + + + {c.already_addressed ? ( + + Already addressed + + ) : null} + + {c.explanation ? : null} + + ); + } + case "signal_finding": { + const c = artefact.content as SignalFindingContent; + return ( + + + + {c.signal_id} + + + {c.verified ? "Verified" : "Unverified"} + + + {c.relevant_code_paths.length > 0 ? ( + + {c.relevant_code_paths.map((path) => ( + + {path} + + ))} + + ) : null} + + ); + } + case "suggested_reviewers": + return ( + + ); + case "dismissal": { + const c = artefact.content as DismissalContent; + return ( + + + {prettify(c.reason)} + + {c.note ? : null} + + ); + } + default: { + const c = artefact.content as SignalReportArtefactContent | null; + const text = typeof c?.content === "string" ? c.content : ""; + return ( + + {text || "No preview available."} + + ); + } + } +} + +function ArtefactRow({ + reportId, + artefact, +}: { + reportId: string; + artefact: AnySignalReportArtefact; +}) { + const [showRaw, setShowRaw] = useState(false); + const location = locationLabel(artefact); + + return ( + + + + + {typeLabel(artefact.type)} + + {location ? ( + + {location} + + ) : null} + + + {/* Dev-only escape hatch for inspecting the raw artefact payload. */} + {import.meta.env.DEV ? ( + + ) : null} + + + + + {showRaw ? ( +
+          {JSON.stringify(artefact, null, 2)}
+        
+ ) : null} +
+ ); +} + +export function ArtefactLogList({ + reportId, + artefacts, +}: { + reportId: string; + artefacts: AnySignalReportArtefact[]; +}) { + if (artefacts.length === 0) { + return null; + } + + const sorted = [...artefacts].sort( + (a, b) => + new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + ); + + return ( + + {sorted.map((artefact) => ( + + ))} + + ); +} diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx new file mode 100644 index 0000000000..ed07a0d98a --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ArtefactTaskRun.tsx @@ -0,0 +1,94 @@ +import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; +import type { Task, TaskRunArtefactContent } from "@posthog/shared/types"; +import { TaskLogsPanel } from "@posthog/ui/features/task-detail/components/TaskLogsPanel"; +import { taskKeys } from "@posthog/ui/features/tasks/taskKeys"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { Badge, Box, Text } from "@radix-ui/themes"; +import { useState } from "react"; + +const SIGNALS_PRODUCT = "signals"; + +// Friendlier labels for the built-in signals-pipeline task types; custom-agent types fall back +// to a humanized form of their identifier. +const SIGNALS_TYPE_LABELS: Record = { + research: "Research", + implementation: "Implementation", + repo_selection: "Repo selection", +}; + +function humanizeIdentifier(value: string): string { + const spaced = value.replace(/[_-]+/g, " ").trim(); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** + * Renders a `task_run` artefact: loads the referenced task and lets the user + * expand it to read the full conversation log (read-only) via `TaskLogsPanel`. + */ +export function ArtefactTaskRun({ + content, +}: { + content: TaskRunArtefactContent; +}) { + const [expanded, setExpanded] = useState(false); + + const taskQuery = useAuthenticatedQuery( + taskKeys.detail(content.task_id), + (client) => client.getTask(content.task_id), + { enabled: !!content.task_id, staleTime: 10_000 }, + ); + + const task = taskQuery.data; + const isSignals = content.product === SIGNALS_PRODUCT; + const label = isSignals + ? (SIGNALS_TYPE_LABELS[content.type] ?? humanizeIdentifier(content.type)) + : humanizeIdentifier(content.type); + const status = task?.latest_run?.status; + + return ( + + + + {taskQuery.isError ? ( + + Couldn’t load this task. + + ) : null} + + {expanded && task ? ( + + + + ) : null} + + ); +} diff --git a/packages/ui/src/features/inbox/components/detail/DiffBlock.tsx b/packages/ui/src/features/inbox/components/detail/DiffBlock.tsx new file mode 100644 index 0000000000..694c3bd8c4 --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/DiffBlock.tsx @@ -0,0 +1,27 @@ +// Renders unified-diff text with per-line +/- coloring. Shared by the `code_diff` +// artefact body and the `commit` artefact's commit-vs-parent diff. +export function DiffBlock({ diff }: { diff: string }) { + const lines = diff.replace(/\n$/, "").split("\n"); + return ( +
+      {lines.map((line, i) => {
+        const added = line.startsWith("+") && !line.startsWith("+++");
+        const removed = line.startsWith("-") && !line.startsWith("---");
+        const hunk = line.startsWith("@@");
+        const cls = added
+          ? "bg-(--green-3) text-(--green-11)"
+          : removed
+            ? "bg-(--red-3) text-(--red-11)"
+            : hunk
+              ? "text-(--gray-10)"
+              : "text-(--gray-12)";
+        return (
+          // biome-ignore lint/suspicious/noArrayIndexKey: stable parse output, never reorders
+          
+ {line || " "} +
+ ); + })} +
+ ); +} diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx b/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx index 0ba76e8af5..225d93b13b 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.test.tsx @@ -68,7 +68,7 @@ describe("useUpdateSuggestedReviewers", () => { vi.clearAllMocks(); }); - it("optimistically patches the suggested_reviewers artefact in the cache", async () => { + it("optimistically appends a new latest reviewers row, keeping the prior one", async () => { mockUpdateArtefact.mockResolvedValue(artefact([reviewer("octocat")])); const { result, queryClient } = renderUpdateHook(); @@ -92,12 +92,21 @@ describe("useUpdateSuggestedReviewers", () => { ]); const cached = queryClient.getQueryData(key); - const cachedArtefact = cached?.results.find((a) => a.id === ARTEFACT_ID) as - | SuggestedReviewersArtefact - | undefined; - expect(cachedArtefact?.content.map((r) => r.github_login)).toEqual([ + const reviewerRows = (cached?.results ?? []).filter( + (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", + ); + // The prior row is preserved untouched as history. + const priorRow = reviewerRows.find((a) => a.id === ARTEFACT_ID); + expect(priorRow?.content.map((r) => r.github_login)).toEqual([ "octocat", + "hubot", ]); + // A new synthetic row is appended and is the latest (current reviewers). + const latest = reviewerRows.reduce((a, b) => + a.created_at > b.created_at ? a : b, + ); + expect(latest.id).not.toBe(ARTEFACT_ID); + expect(latest.content.map((r) => r.github_login)).toEqual(["octocat"]); }); it("rolls back the cache when the request fails", async () => { diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.ts b/packages/ui/src/features/inbox/hooks/useInboxReports.ts index 1b2edb11e2..7049162963 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.ts @@ -225,15 +225,17 @@ export function useInboxReportSignals( interface UpdateSuggestedReviewersVariables { artefactId: string; - /** Full-replacement payload sent to the server. */ + /** Reviewer list sent to the server (it appends a new suggested_reviewers status row). */ content: SuggestedReviewerWriteEntry[]; - /** Read-shape list used to optimistically patch the cache for immediate-apply UI. */ + /** Read-shape list used to optimistically show the new current reviewers. */ optimisticReviewers: SuggestedReviewersArtefact["content"]; } /** - * Persists a full replacement of a report's `suggested_reviewers` artefact and optimistically - * patches the cached artefacts so the detail pane reflects the change instantly (immediate apply). + * Edits a report's suggested reviewers. The server appends a new `suggested_reviewers` status + * artefact (latest-wins), so the work-log keeps the full history of changes. We optimistically + * append a synthetic latest row — mirroring the server — so the detail pane reflects the change + * instantly (immediate apply); the refetch on settle reconciles it with the real row. */ export function useUpdateSuggestedReviewers(reportId: string) { const queryClient = useQueryClient(); @@ -247,23 +249,25 @@ export function useUpdateSuggestedReviewers(reportId: string) { (client, { artefactId, content }) => client.updateSignalReportArtefact(reportId, artefactId, content), { - onMutate: async ({ artefactId, optimisticReviewers }) => { + onMutate: async ({ optimisticReviewers }) => { await queryClient.cancelQueries({ queryKey }); const previous = queryClient.getQueryData(queryKey); if (previous) { + // Append a synthetic latest row rather than mutating the current one — "current + // reviewers" is derived as the latest suggested_reviewers artefact, so a row stamped + // now wins, and the prior row stays in the log as history (matching the server). + const optimisticRow: SuggestedReviewersArtefact = { + id: `optimistic-${Date.now()}`, + type: "suggested_reviewers", + content: optimisticReviewers, + created_at: new Date().toISOString(), + }; queryClient.setQueryData(queryKey, { ...previous, - results: previous.results.map((artefact) => - artefact.id === artefactId && - artefact.type === "suggested_reviewers" - ? ({ - ...artefact, - content: optimisticReviewers, - } as SuggestedReviewersArtefact) - : artefact, - ), + results: [...previous.results, optimisticRow], + count: previous.count + 1, }); } From 56ee182d226dbc64afb10ce3decfd756cf6ed34f Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 16:09:26 +0300 Subject: [PATCH 03/13] feat(inbox): derive task purposes from task_run artefacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SignalReportTask is now an unlabelled task↔report association — the relationship label is gone from the API. A task's purpose (Research / Implementation / a custom agent's humanized identifier) is derived from the report's task_run artefacts; freely-associated tasks with no task_run artefact surface generically as "Agent task". Task creation stops sending the relationship label, and the report repository falls back to walking associated tasks oldest-first. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- packages/core/src/inbox/reportRepository.ts | 20 ++--- packages/core/src/inbox/reportTasks.ts | 33 +------ .../core/src/task-detail/taskCreationSaga.ts | 10 +-- .../inbox/components/AgentRunDetail.tsx | 34 +++---- .../inbox/components/ReportTasksSection.tsx | 23 ++--- .../features/inbox/hooks/useReportTasks.ts | 89 ++++++++++++++++--- 6 files changed, 106 insertions(+), 103 deletions(-) diff --git a/packages/core/src/inbox/reportRepository.ts b/packages/core/src/inbox/reportRepository.ts index 5fb7ebe276..cbc458bb04 100644 --- a/packages/core/src/inbox/reportRepository.ts +++ b/packages/core/src/inbox/reportRepository.ts @@ -1,19 +1,19 @@ import type { SignalReportTask, Task } from "@posthog/shared/domain-types"; -export const REPOSITORY_SOURCE_RELATIONSHIPS: SignalReportTask["relationship"][] = - ["repo_selection", "research", "implementation"]; - +/** + * Resolve the repository a report's work happened in. Associations are + * unlabelled — the repository is simply the first one any associated task + * carries, walking oldest-first (repo selection / research precede + * implementation). + */ export async function resolveReportRepository( reportTasks: SignalReportTask[], getTask: (taskId: string) => Promise, ): Promise { - for (const relationship of REPOSITORY_SOURCE_RELATIONSHIPS) { - const reportTask = reportTasks.find( - (task) => task.relationship === relationship, - ); - if (!reportTask) { - continue; - } + const ordered = [...reportTasks].sort((a, b) => + a.created_at.localeCompare(b.created_at), + ); + for (const reportTask of ordered) { const task = await getTask(reportTask.task_id); if (task?.repository) { return task.repository.toLowerCase(); diff --git a/packages/core/src/inbox/reportTasks.ts b/packages/core/src/inbox/reportTasks.ts index 8f2d45fc9d..999f319d87 100644 --- a/packages/core/src/inbox/reportTasks.ts +++ b/packages/core/src/inbox/reportTasks.ts @@ -1,35 +1,4 @@ -import type { SignalReportTask, Task } from "@posthog/shared/domain-types"; - -export type ReportTaskRelationship = SignalReportTask["relationship"]; - -export const DISPLAYED_RELATIONSHIPS: ReportTaskRelationship[] = [ - "implementation", - "research", -]; - -export interface ReportTaskData { - task: Task; - relationship: ReportTaskRelationship; - startedAt: string; -} - -/** Keep only report-task relationships that the detail pane renders. */ -export function selectDisplayedReportTasks( - reportTasks: SignalReportTask[], -): SignalReportTask[] { - return reportTasks.filter((rt) => - DISPLAYED_RELATIONSHIPS.includes(rt.relationship), - ); -} - -/** Sort report tasks by their relationship's display rank. */ -export function sortByRelationship(tasks: ReportTaskData[]): ReportTaskData[] { - return [...tasks].sort( - (a, b) => - DISPLAYED_RELATIONSHIPS.indexOf(a.relationship) - - DISPLAYED_RELATIONSHIPS.indexOf(b.relationship), - ); -} +import type { Task } from "@posthog/shared/domain-types"; /** Extract the PR url from a task's latest run output, if present. */ export function getTaskPrUrl(task: Task): string | null { diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 42a5322927..96e9e9f453 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -12,10 +12,7 @@ import { type Workspace, } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { - SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP, - type Task, -} from "@posthog/shared/domain-types"; +import type { Task } from "@posthog/shared/domain-types"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; import type { ITaskCreationHost } from "./taskCreationHost"; @@ -416,10 +413,9 @@ export class TaskCreationSaga extends Saga< origin_product: input.signalReportId ? "signal_report" : "user_created", + // The server associates the task with the report and records the implementation + // task_run artefact — no relationship label is sent (associations are unlabelled). signal_report: input.signalReportId ?? undefined, - signal_report_task_relationship: input.signalReportId - ? SIGNAL_REPORT_TASK_IMPLEMENTATION_RELATIONSHIP - : undefined, }); return result as unknown as Task; }, diff --git a/packages/ui/src/features/inbox/components/AgentRunDetail.tsx b/packages/ui/src/features/inbox/components/AgentRunDetail.tsx index d98e7078f6..c5184ca933 100644 --- a/packages/ui/src/features/inbox/components/AgentRunDetail.tsx +++ b/packages/ui/src/features/inbox/components/AgentRunDetail.tsx @@ -16,8 +16,6 @@ import { Button } from "@posthog/quill"; import { isTerminalStatus, type SignalReport, - type SignalReportTaskRelationship, - type Task, type TaskRunStatus, } from "@posthog/shared/types"; import { @@ -47,7 +45,10 @@ import { hasKnownSourceProduct, } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; import { useInboxReportSignals } from "@posthog/ui/features/inbox/hooks/useInboxReports"; -import { useReportTasks } from "@posthog/ui/features/inbox/hooks/useReportTasks"; +import { + type ReportTaskData, + useReportTasks, +} from "@posthog/ui/features/inbox/hooks/useReportTasks"; import { TaskLogsPanel } from "@posthog/ui/features/task-detail/components/TaskLogsPanel"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { openTask } from "@posthog/ui/router/useOpenTask"; @@ -72,21 +73,8 @@ export function TaskRunStatusDot({ status }: { status: TaskRunStatus }) { ); } -export const RELATIONSHIP_LABEL: Record = - { - research: "Research", - implementation: "Implementation", - repo_selection: "Repo selection", - }; - -interface RelevantTask { - task: Task; - relationship: SignalReportTaskRelationship; - startedAt: string; -} - /** Prefer in-motion tasks; tie-break by most-recently-created. */ -function pickPrimaryTask(tasks: RelevantTask[]): RelevantTask | null { +function pickPrimaryTask(tasks: ReportTaskData[]): ReportTaskData | null { if (tasks.length === 0) return null; return [...tasks].sort((a, b) => { const aInMotion = !isTerminalStatus(a.task.latest_run?.status ?? ""); @@ -261,9 +249,7 @@ function AgentRunDetailContent({ report }: { report: SignalReport }) { // regardless of how the first attempt ended. const isReResearch = useMemo(() => { if (!reportTasks) return false; - const researchTasks = reportTasks.filter( - (rt) => rt.relationship === "research", - ); + const researchTasks = reportTasks.filter((rt) => rt.purpose === "research"); if (researchTasks.length < 2) return false; const hasInFlight = researchTasks.some( (rt) => @@ -456,8 +442,8 @@ function TaskLogRightSlot({ selectedEntry, onSelect, }: { - entries: RelevantTask[]; - selectedEntry: RelevantTask | null | undefined; + entries: ReportTaskData[]; + selectedEntry: ReportTaskData | null | undefined; onSelect: (id: string) => void; }) { if (!selectedEntry) return null; @@ -479,7 +465,7 @@ function TaskLogRightSlot({ - {RELATIONSHIP_LABEL[selectedEntry.relationship]} + {selectedEntry.purposeLabel} @@ -494,7 +480,7 @@ function TaskLogRightSlot({ - {RELATIONSHIP_LABEL[entry.relationship]} + {entry.purposeLabel} {entry.task.id.slice(0, 8)} diff --git a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx index c4da968d50..905d6bb3a4 100644 --- a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx +++ b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx @@ -1,13 +1,6 @@ import { ArrowSquareOutIcon, TerminalIcon } from "@phosphor-icons/react"; -import type { - SignalReport, - SignalReportTaskRelationship, - Task, -} from "@posthog/shared/types"; -import { - RELATIONSHIP_LABEL, - TaskRunStatusDot, -} from "@posthog/ui/features/inbox/components/AgentRunDetail"; +import type { SignalReport, Task } from "@posthog/shared/types"; +import { TaskRunStatusDot } from "@posthog/ui/features/inbox/components/AgentRunDetail"; import { RightColumnSection } from "@posthog/ui/features/inbox/components/RightColumnSection"; import { useReportTasks } from "@posthog/ui/features/inbox/hooks/useReportTasks"; import { Flex, Text } from "@radix-ui/themes"; @@ -30,11 +23,11 @@ export function ReportTasksSection({ report }: ReportTasksSectionProps) { return ( - {reportTasks.map(({ task, relationship }) => ( + {reportTasks.map(({ task, purposeLabel }) => ( navigate({ to: "/code/inbox/runs/$reportId", @@ -50,11 +43,11 @@ export function ReportTasksSection({ report }: ReportTasksSectionProps) { function TaskRow({ task, - relationship, + purposeLabel, onOpen, }: { task: Task; - relationship: SignalReportTaskRelationship; + purposeLabel: string; onOpen: () => void; }) { const status = task.latest_run?.status ?? "not_started"; @@ -65,9 +58,7 @@ function TaskRow({ className="group flex cursor-default select-none items-center gap-2 rounded-(--radius-1) px-1.5 py-1 text-left text-[11px] transition-colors hover:bg-(--gray-3)" > - - {RELATIONSHIP_LABEL[relationship]} - + {purposeLabel} {task.title || "Untitled"} diff --git a/packages/ui/src/features/inbox/hooks/useReportTasks.ts b/packages/ui/src/features/inbox/hooks/useReportTasks.ts index db4d4e37d0..43e9ff9b7f 100644 --- a/packages/ui/src/features/inbox/hooks/useReportTasks.ts +++ b/packages/ui/src/features/inbox/hooks/useReportTasks.ts @@ -1,20 +1,59 @@ import type { SignalReportStatus, - SignalReportTask, Task, + TaskRunArtefactContent, } from "@posthog/shared/types"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; -type Relationship = SignalReportTask["relationship"]; +// Task↔report associations are unlabelled — a task's purpose is derived from the report's +// `task_run` artefacts (the signals pipeline writes product="signals" with one of these types; +// custom agents write their own (product, type) pair). +export type ReportTaskPurpose = "research" | "implementation" | "other"; -const DISPLAYED_RELATIONSHIPS: Relationship[] = ["implementation", "research"]; - -interface ReportTaskData { +export interface ReportTaskData { task: Task; - relationship: Relationship; + purpose: ReportTaskPurpose; + /** Human-readable row label — "Research" / "Implementation" / a humanized custom pair. */ + purposeLabel: string; startedAt: string; } +function humanizeIdentifier(value: string): string { + const words = value.replace(/[_-]+/g, " ").trim(); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function derivePurpose( + taskRun: { product: string; type: string } | undefined, +): { purpose: ReportTaskPurpose; purposeLabel: string } | null { + if (!taskRun) { + // Freely-associated task with no task_run artefact (e.g. an agent that associated itself + // mid-run) — surface it generically rather than hiding it. + return { purpose: "other", purposeLabel: "Agent task" }; + } + if (taskRun.product === "signals") { + if (taskRun.type === "research") { + return { purpose: "research", purposeLabel: "Research" }; + } + if (taskRun.type === "implementation") { + return { purpose: "implementation", purposeLabel: "Implementation" }; + } + // repo_selection runs are plumbing, not report work — never displayed (matches the + // pre-derivation behavior of only showing research/implementation). + return null; + } + return { + purpose: "other", + purposeLabel: `${humanizeIdentifier(taskRun.product)} — ${humanizeIdentifier(taskRun.type)}`, + }; +} + +const PURPOSE_ORDER: ReportTaskPurpose[] = [ + "implementation", + "research", + "other", +]; + export function useReportTasks( reportId: string, reportStatus: SignalReportStatus, @@ -27,24 +66,46 @@ export function useReportTasks( return useAuthenticatedQuery( ["inbox", "report-tasks", reportId], async (client) => { - const reportTasks = await client.getSignalReportTasks(reportId); - const relevant = reportTasks.filter((rt) => - DISPLAYED_RELATIONSHIPS.includes(rt.relationship), - ); + const [reportTasks, artefacts] = await Promise.all([ + client.getSignalReportTasks(reportId), + client.getSignalReportArtefacts(reportId), + ]); + // task id → its (product, type) from the report's task_run artefacts. The runtime + // `type` check is authoritative (the generic fallback artefact keeps `type: string` + // and defeats static narrowing). + const taskRunByTaskId = new Map< + string, + { product: string; type: string } + >(); + for (const artefact of artefacts.results) { + if (artefact.type === "task_run") { + const content = artefact.content as TaskRunArtefactContent; + taskRunByTaskId.set(content.task_id, { + product: content.product, + type: content.type, + }); + } + } + + const relevant = reportTasks.flatMap((rt) => { + const derived = derivePurpose(taskRunByTaskId.get(rt.task_id)); + return derived ? [{ rt, ...derived }] : []; + }); + const tasks = await Promise.all( - relevant.map(async (rt) => { + relevant.map(async ({ rt, purpose, purposeLabel }) => { const task = await client.getTask(rt.task_id); return { task, - relationship: rt.relationship, + purpose, + purposeLabel, startedAt: rt.created_at, }; }), ); return tasks.sort( (a, b) => - DISPLAYED_RELATIONSHIPS.indexOf(a.relationship) - - DISPLAYED_RELATIONSHIPS.indexOf(b.relationship), + PURPOSE_ORDER.indexOf(a.purpose) - PURPOSE_ORDER.indexOf(b.purpose), ); }, { From 6bee09da245c012e0ab760f0589ad0030fd7f31b Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 16:16:17 +0300 Subject: [PATCH 04/13] refactor(inbox): move the activity log into the right column Renders as a RightColumnSection below the report's status sections (Runs, Suggested reviewers) instead of under the summary, and drops the now-unused belowSummary slot from InboxDetailFrame. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- .../inbox/components/InboxDetailFrame.tsx | 6 +--- .../inbox/components/ReportDetail.tsx | 30 +++++++++---------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx index 0211799d37..ab629a6cba 100644 --- a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx +++ b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx @@ -49,8 +49,6 @@ interface InboxDetailFrameProps { Icon: ComponentType; title: string; } | null; - /** Optional section(s) rendered below the summary in the left column (e.g. the activity log). */ - belowSummary?: ReactNode; /** Sections rendered alongside the summary (Tasks, Suggested reviewers, …). */ children?: ReactNode; } @@ -74,7 +72,6 @@ export function InboxDetailFrame({ primaryAction, summarySection, evidenceSection, - belowSummary, children, }: InboxDetailFrameProps) { const { data: signalsResp } = useInboxReportSignals(report.id); @@ -170,7 +167,7 @@ export function InboxDetailFrame({ */}
-
+
- {belowSummary}
diff --git a/packages/ui/src/features/inbox/components/ReportDetail.tsx b/packages/ui/src/features/inbox/components/ReportDetail.tsx index c6ab5fac37..4dd1f12068 100644 --- a/packages/ui/src/features/inbox/components/ReportDetail.tsx +++ b/packages/ui/src/features/inbox/components/ReportDetail.tsx @@ -6,12 +6,12 @@ import { } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; -import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; import { ArtefactLogList } from "@posthog/ui/features/inbox/components/detail/ArtefactLogList"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; import { ReportDetailActions } from "@posthog/ui/features/inbox/components/ReportDetailActions"; import { ReportTasksSection } from "@posthog/ui/features/inbox/components/ReportTasksSection"; +import { RightColumnSection } from "@posthog/ui/features/inbox/components/RightColumnSection"; import { SuggestedReviewersSection } from "@posthog/ui/features/inbox/components/SuggestedReviewersSection"; import { useInboxReportArtefacts } from "@posthog/ui/features/inbox/hooks/useInboxReports"; import { Text } from "@radix-ui/themes"; @@ -73,24 +73,22 @@ function ReportDetailContent({ report }: { report: SignalReport }) { } summarySection={{ Icon: FileTextIcon, title: "Summary" }} evidenceSection={{ Icon: MagnifyingGlassIcon, title: "Evidence" }} - belowSummary={ - artefacts.length > 0 ? ( - - {artefacts.length} entr{artefacts.length === 1 ? "y" : "ies"} - - } - > - - - ) : null - } > + {artefacts.length > 0 ? ( + + {artefacts.length} entr{artefacts.length === 1 ? "y" : "ies"} + + } + > + + + ) : null} ); } From 785e80a637d563ac216af2013b8297db0a8befbb Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 16:17:43 +0300 Subject: [PATCH 05/13] feat(inbox): collapse judgment reasoning in the activity log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priority and actionability entries show their badge with the explanation behind a "Show reasoning" toggle — the paragraphs were dominating the log. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- .../components/detail/ArtefactLogList.tsx | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx index caa8fde8ec..2bacdace54 100644 --- a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx +++ b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx @@ -1,4 +1,8 @@ -import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { + ArrowSquareOutIcon, + CaretDownIcon, + CaretRightIcon, +} from "@phosphor-icons/react"; import type { ActionabilityJudgmentContent, AnySignalReportArtefact, @@ -102,6 +106,32 @@ function RelevanceNote({ note }: { note: string }) { return {note}; } +/** Judgment explanations are often paragraphs — collapsed by default behind a toggle. */ +function CollapsibleReasoning({ text }: { text: string }) { + const [expanded, setExpanded] = useState(false); + if (!text.trim()) return null; + return ( + + + {expanded ? ( + {text} + ) : null} + + ); +} + function ReviewersBody({ reviewers }: { reviewers: SuggestedReviewer[] }) { if (reviewers.length === 0) { return ( @@ -224,7 +254,7 @@ function ArtefactBody({ return ( - {c.explanation ? : null} + {c.explanation ? : null} ); } @@ -240,7 +270,7 @@ function ArtefactBody({ ) : null} - {c.explanation ? : null} + {c.explanation ? : null} ); } From 3484e6ccf07f238040214736b34447fd18272311 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 16:19:59 +0300 Subject: [PATCH 06/13] feat(inbox): collapse note entries in the activity log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes are free-form markdown and can run long — collapsed they show a truncated first-line preview that expands to the rendered note + author. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- .../components/detail/ArtefactLogList.tsx | 60 +++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx index 2bacdace54..9b88dfd02a 100644 --- a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx +++ b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx @@ -132,6 +132,55 @@ function CollapsibleReasoning({ text }: { text: string }) { ); } +/** + * Notes are free-form markdown and can run long — collapsed to a one-line + * preview (the first non-empty line) that expands to the rendered note. + */ +function CollapsibleNote({ + note, + author, +}: { + note: string; + author?: string | null; +}) { + const [expanded, setExpanded] = useState(false); + const preview = note + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + return ( + + + {expanded ? ( + + + {author ? ( + + — {author} + + ) : null} + + ) : null} + + ); +} + function ReviewersBody({ reviewers }: { reviewers: SuggestedReviewer[] }) { if (reviewers.length === 0) { return ( @@ -238,16 +287,7 @@ function ArtefactBody({ ); case "note": { const c = artefact.content as NoteContent; - return ( - - - {c.author ? ( - - — {c.author} - - ) : null} - - ); + return ; } case "priority_judgment": { const c = artefact.content as PriorityJudgmentContent; From 7cb0e74552f1cae4ad27a13b1339b597b6b52a57 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 19:03:54 +0300 Subject: [PATCH 07/13] =?UTF-8?q?feat(inbox):=20derive=20task=E2=86=94repo?= =?UTF-8?q?rt=20association=20from=20task=5Frun=20artefacts=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend removed the /signals/reports/{id}/tasks/ surface — task_run artefacts are the sole source of association. useReportTasks now reads them directly (one entry per associated task, earliest artefact wins for startedAt), getSignalReportTasks and the SignalReportTask type are gone, and resolveReportRepository walks artefact-derived task ids oldest-first. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- apps/mobile/src/features/inbox/api.ts | 23 ------- apps/mobile/src/features/inbox/types.ts | 7 --- packages/api-client/src/posthog-client.ts | 24 -------- packages/core/src/inbox/reportRepository.ts | 20 +++---- packages/shared/src/domain-types.ts | 11 ---- .../features/inbox/hooks/useReportTasks.ts | 60 +++++++++---------- 6 files changed, 40 insertions(+), 105 deletions(-) diff --git a/apps/mobile/src/features/inbox/api.ts b/apps/mobile/src/features/inbox/api.ts index db72f0d163..110633cc04 100644 --- a/apps/mobile/src/features/inbox/api.ts +++ b/apps/mobile/src/features/inbox/api.ts @@ -15,7 +15,6 @@ import type { SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, - SignalReportTask, SuggestedReviewerWriteEntry, } from "./types"; @@ -152,28 +151,6 @@ export async function getAvailableSuggestedReviewers( return { results, count: results.length }; } -export async function getSignalReportTasks( - reportId: string, -): Promise { - const baseUrl = getBaseUrl(); - const projectId = getProjectId(); - - const response = await authedFetch( - `${baseUrl}/api/projects/${projectId}/signals/reports/${reportId}/tasks/`, - ); - - if (!response.ok) { - throw new HttpError( - response.status, - response.statusText, - "Failed to fetch signal report tasks", - ); - } - - const data = await response.json(); - return data.results ?? []; -} - export async function getSignalReportArtefacts( reportId: string, ): Promise { diff --git a/apps/mobile/src/features/inbox/types.ts b/apps/mobile/src/features/inbox/types.ts index 04afb0bff6..33e1674fb9 100644 --- a/apps/mobile/src/features/inbox/types.ts +++ b/apps/mobile/src/features/inbox/types.ts @@ -72,13 +72,6 @@ export interface AvailableSuggestedReviewersResponse { count: number; } -export interface SignalReportTask { - id: string; - relationship: string; - task_id: string; - created_at: string; -} - export interface Signal { signal_id: string; content: string; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 78fd1cf609..9b1111b9aa 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -36,7 +36,6 @@ import type { SignalReportSignalsResponse, SignalReportsQueryParams, SignalReportsResponse, - SignalReportTask, SignalTeamConfig, SignalUserAutonomyConfig, SlackChannelsQueryParams, @@ -3050,29 +3049,6 @@ export class PostHogAPIClient { }; } - async getSignalReportTasks(reportId: string): Promise { - const teamId = await this.getTeamId(); - const url = new URL( - `${this.api.baseUrl}/api/projects/${teamId}/signals/reports/${reportId}/tasks/`, - ); - const path = `/api/projects/${teamId}/signals/reports/${reportId}/tasks/`; - - const response = await this.api.fetcher.fetch({ - method: "get", - url, - path, - }); - - if (!response.ok) { - throw new Error( - `Failed to fetch signal report tasks: ${response.statusText}`, - ); - } - - const data = (await response.json()) as { results?: SignalReportTask[] }; - return data.results ?? []; - } - async getSignalTeamConfig(): Promise { const teamId = await this.getTeamId(); const url = new URL( diff --git a/packages/core/src/inbox/reportRepository.ts b/packages/core/src/inbox/reportRepository.ts index cbc458bb04..a5e9f1ee90 100644 --- a/packages/core/src/inbox/reportRepository.ts +++ b/packages/core/src/inbox/reportRepository.ts @@ -1,20 +1,20 @@ -import type { SignalReportTask, Task } from "@posthog/shared/domain-types"; +import type { Task } from "@posthog/shared/domain-types"; /** - * Resolve the repository a report's work happened in. Associations are - * unlabelled — the repository is simply the first one any associated task - * carries, walking oldest-first (repo selection / research precede - * implementation). + * Resolve the repository a report's work happened in. Association is derived + * from `task_run` artefacts — the repository is simply the first one any + * associated task carries, walking oldest-first (repo selection / research + * precede implementation). */ export async function resolveReportRepository( - reportTasks: SignalReportTask[], + associatedTasks: Array<{ taskId: string; startedAt: string }>, getTask: (taskId: string) => Promise, ): Promise { - const ordered = [...reportTasks].sort((a, b) => - a.created_at.localeCompare(b.created_at), + const ordered = [...associatedTasks].sort((a, b) => + a.startedAt.localeCompare(b.startedAt), ); - for (const reportTask of ordered) { - const task = await getTask(reportTask.task_id); + for (const entry of ordered) { + const task = await getTask(entry.taskId); if (task?.repository) { return task.repository.toLowerCase(); } diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 36c37af189..8e6c1b8fb4 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -634,17 +634,6 @@ export interface SignalReportsQueryParams { priority?: string; } -/** - * An unlabelled task↔report association. A task's purpose (research / implementation / …) - * is derived from the report's `task_run` artefacts, not stored on the link. - */ -export interface SignalReportTask { - id: string; - report_id: string; - task_id: string; - created_at: string; -} - export interface SignalTeamConfig { id: string; default_autostart_priority: SignalReportPriority; diff --git a/packages/ui/src/features/inbox/hooks/useReportTasks.ts b/packages/ui/src/features/inbox/hooks/useReportTasks.ts index 43e9ff9b7f..0d1aed2161 100644 --- a/packages/ui/src/features/inbox/hooks/useReportTasks.ts +++ b/packages/ui/src/features/inbox/hooks/useReportTasks.ts @@ -23,14 +23,10 @@ function humanizeIdentifier(value: string): string { return words.charAt(0).toUpperCase() + words.slice(1); } -function derivePurpose( - taskRun: { product: string; type: string } | undefined, -): { purpose: ReportTaskPurpose; purposeLabel: string } | null { - if (!taskRun) { - // Freely-associated task with no task_run artefact (e.g. an agent that associated itself - // mid-run) — surface it generically rather than hiding it. - return { purpose: "other", purposeLabel: "Agent task" }; - } +function derivePurpose(taskRun: { + product: string; + type: string; +}): { purpose: ReportTaskPurpose; purposeLabel: string } | null { if (taskRun.product === "signals") { if (taskRun.type === "research") { return { purpose: "research", purposeLabel: "Research" }; @@ -66,40 +62,44 @@ export function useReportTasks( return useAuthenticatedQuery( ["inbox", "report-tasks", reportId], async (client) => { - const [reportTasks, artefacts] = await Promise.all([ - client.getSignalReportTasks(reportId), - client.getSignalReportArtefacts(reportId), - ]); - // task id → its (product, type) from the report's task_run artefacts. The runtime - // `type` check is authoritative (the generic fallback artefact keeps `type: string` - // and defeats static narrowing). + // task_run artefacts ARE the task↔report association — one entry per associated task, + // keyed by content.task_id (earliest artefact wins for startedAt). The runtime `type` + // check is authoritative (the generic fallback artefact keeps `type: string` and + // defeats static narrowing). + const artefacts = await client.getSignalReportArtefacts(reportId); const taskRunByTaskId = new Map< string, - { product: string; type: string } + { product: string; type: string; startedAt: string } >(); for (const artefact of artefacts.results) { - if (artefact.type === "task_run") { - const content = artefact.content as TaskRunArtefactContent; - taskRunByTaskId.set(content.task_id, { - product: content.product, - type: content.type, - }); - } + if (artefact.type !== "task_run") continue; + const content = artefact.content as TaskRunArtefactContent; + const existing = taskRunByTaskId.get(content.task_id); + if (existing && existing.startedAt <= artefact.created_at) continue; + taskRunByTaskId.set(content.task_id, { + product: content.product, + type: content.type, + startedAt: artefact.created_at, + }); } - const relevant = reportTasks.flatMap((rt) => { - const derived = derivePurpose(taskRunByTaskId.get(rt.task_id)); - return derived ? [{ rt, ...derived }] : []; - }); + const relevant = [...taskRunByTaskId.entries()].flatMap( + ([taskId, run]) => { + const derived = derivePurpose(run); + return derived + ? [{ taskId, startedAt: run.startedAt, ...derived }] + : []; + }, + ); const tasks = await Promise.all( - relevant.map(async ({ rt, purpose, purposeLabel }) => { - const task = await client.getTask(rt.task_id); + relevant.map(async ({ taskId, startedAt, purpose, purposeLabel }) => { + const task = await client.getTask(taskId); return { task, purpose, purposeLabel, - startedAt: rt.created_at, + startedAt, }; }), ); From 8bdb70706818a4b5e0b89cc3e7322f15e21cf5b7 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Thu, 11 Jun 2026 19:16:24 +0300 Subject: [PATCH 08/13] feat(inbox): show artefact attribution in the activity log The API already attributes every artefact (created_by for user writes, task_id for agent writes) but the client normalizers dropped both fields, so the log had no byline. Thread them through a shared artefact base type and render who produced each entry next to its timestamp. Generated-By: PostHog Code Task-Id: 03657aa4-20d8-4dea-aeef-e8c1ab4bbce2 --- packages/api-client/src/posthog-client.ts | 53 ++++++++----- packages/shared/src/domain-types.ts | 76 +++++++------------ .../components/detail/ArtefactLogList.tsx | 20 +++++ 3 files changed, 85 insertions(+), 64 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 9b1111b9aa..e1c8088a9a 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -45,6 +45,7 @@ import type { Task, TaskRun, TaskRunArtefact, + UserBasic, } from "@posthog/shared/domain-types"; import { buildApiFetcher } from "./fetcher"; import { createApiClient, type Schemas } from "./generated"; @@ -487,7 +488,7 @@ function normalizePriorityJudgmentArtefact( return { id, type: "priority_judgment", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { explanation: optionalString(contentValue.explanation) ?? "", priority: priority as PriorityJudgmentArtefact["content"]["priority"], @@ -519,7 +520,7 @@ function normalizeActionabilityJudgmentArtefact( return { id, type: "actionability_judgment", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { explanation: optionalString(contentValue.explanation) ?? "", actionability: @@ -547,7 +548,7 @@ function normalizeSignalFindingArtefact( return { id, type: "signal_finding", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { signal_id: signalId, relevant_code_paths: Array.isArray(contentValue.relevant_code_paths) @@ -585,7 +586,7 @@ function normalizeRepoSelectionArtefact( return { id, type: "repo_selection", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { repository: optionalString(contentValue.repository), reason: optionalString(contentValue.reason) ?? "", @@ -615,7 +616,7 @@ function normalizeDismissalArtefact( return { id, type: "dismissal", - created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + ...artefactBase(value), content: { reason, note: optionalString(contentValue.note) ?? "", @@ -631,13 +632,34 @@ function normalizeDismissalArtefact( // session_id shape the generic fallback expects), so each type needs an explicit // normalizer — otherwise it falls through and gets dropped. -function logArtefactBase(value: Record): { +/** User the artefact is attributed to, when the row carries a valid `created_by`. */ +function normalizeArtefactUser(value: unknown): UserBasic | null { + if (!isObjectRecord(value)) return null; + const id = value.id; + const uuid = optionalString(value.uuid); + const email = optionalString(value.email); + if (typeof id !== "number" || !uuid || !email) return null; + return { + id, + uuid, + email, + first_name: optionalString(value.first_name) ?? undefined, + last_name: optionalString(value.last_name) ?? undefined, + }; +} + +/** Row-level fields shared by every artefact: timestamps plus user/task attribution. */ +function artefactBase(value: Record): { created_at: string; updated_at: string | null; + created_by: UserBasic | null; + task_id: string | null; } { return { created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), updated_at: optionalString(value.updated_at), + created_by: normalizeArtefactUser(value.created_by), + task_id: optionalString(value.task_id), }; } @@ -654,7 +676,7 @@ function normalizeCodeReferenceArtefact( return { id, type: "code_reference", - ...logArtefactBase(value), + ...artefactBase(value), content: { file_path, start_line: typeof c.start_line === "number" ? c.start_line : 0, @@ -678,7 +700,7 @@ function normalizeCodeDiffArtefact( return { id, type: "code_diff", - ...logArtefactBase(value), + ...artefactBase(value), content: { file_path, diff: optionalString(c.diff) ?? "", @@ -700,7 +722,7 @@ function normalizeLineReferenceArtefact( return { id, type: "line_reference", - ...logArtefactBase(value), + ...artefactBase(value), content: { file_path, line: typeof c.line === "number" ? c.line : 0, @@ -725,8 +747,7 @@ function normalizeCommitArtefact( return { id, type: "commit", - ...logArtefactBase(value), - task_id: optionalString(value.task_id), + ...artefactBase(value), content: { repository, branch, @@ -753,7 +774,7 @@ function normalizeTaskRunArtefact( return { id, type: "task_run", - ...logArtefactBase(value), + ...artefactBase(value), content: { task_id, run_id: optionalString(c.run_id), @@ -776,7 +797,7 @@ function normalizeNoteArtefact( return { id, type: "note", - ...logArtefactBase(value), + ...artefactBase(value), content: { note, author: optionalString(c.author), @@ -830,15 +851,13 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { } const type = dispatchType ?? "unknown"; - const created_at = - optionalString(value.created_at) ?? new Date(0).toISOString(); // suggested_reviewers: content is an array of reviewer objects if (type === "suggested_reviewers" && Array.isArray(value.content)) { return { id, type: "suggested_reviewers" as const, - created_at, + ...artefactBase(value), content: value.content as SuggestedReviewersArtefact["content"], }; } @@ -860,7 +879,7 @@ function normalizeSignalReportArtefact(value: unknown): AnyArtefact | null { return { id, type, - created_at, + ...artefactBase(value), content: { session_id: sessionId ?? "", start_time: optionalString(contentValue.start_time) ?? "", diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 8e6c1b8fb4..740bed9373 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -26,7 +26,7 @@ export const effortLevelSchema = z.enum([ ]); export type EffortLevel = z.infer; -interface UserBasic { +export interface UserBasic { id: number; uuid: string; distinct_id?: string | null; @@ -305,19 +305,30 @@ export interface SignalReportArtefactContent { distance_to_centroid: number | null; } -export interface SignalReportArtefact { +/** + * Fields shared by every artefact row. `created_by` / `task_id` carry attribution: + * at most one is set — `created_by` for user writes, `task_id` for agent writes, + * neither for system (pipeline) writes. + */ +export interface SignalReportArtefactBase { id: string; + created_at: string; + updated_at?: string | null; + /** User the artefact is attributed to, when a user produced it. */ + created_by?: UserBasic | null; + /** Task the artefact is attributed to, when an agent produced it. */ + task_id?: string | null; +} + +export interface SignalReportArtefact extends SignalReportArtefactBase { type: string; content: SignalReportArtefactContent; - created_at: string; } /** Artefact with `type: "priority_judgment"` — priority assessment from the agentic report. */ -export interface PriorityJudgmentArtefact { - id: string; +export interface PriorityJudgmentArtefact extends SignalReportArtefactBase { type: "priority_judgment"; content: PriorityJudgmentContent; - created_at: string; } export interface PriorityJudgmentContent { @@ -326,11 +337,10 @@ export interface PriorityJudgmentContent { } /** Artefact with `type: "actionability_judgment"` — actionability assessment from the agentic report. */ -export interface ActionabilityJudgmentArtefact { - id: string; +export interface ActionabilityJudgmentArtefact + extends SignalReportArtefactBase { type: "actionability_judgment"; content: ActionabilityJudgmentContent; - created_at: string; } export interface ActionabilityJudgmentContent { @@ -340,11 +350,9 @@ export interface ActionabilityJudgmentContent { } /** Artefact with `type: "signal_finding"` — per-signal research finding from the agentic report. */ -export interface SignalFindingArtefact { - id: string; +export interface SignalFindingArtefact extends SignalReportArtefactBase { type: "signal_finding"; content: SignalFindingContent; - created_at: string; } export interface SignalFindingContent { @@ -356,11 +364,9 @@ export interface SignalFindingContent { } /** Artefact with `type: "repo_selection"` - selected repository for the report run. */ -export interface RepoSelectionArtefact { - id: string; +export interface RepoSelectionArtefact extends SignalReportArtefactBase { type: "repo_selection"; content: RepoSelectionContent; - created_at: string; } export interface RepoSelectionContent { @@ -369,19 +375,15 @@ export interface RepoSelectionContent { } /** Artefact with `type: "suggested_reviewers"` — content is an enriched reviewer list. */ -export interface SuggestedReviewersArtefact { - id: string; +export interface SuggestedReviewersArtefact extends SignalReportArtefactBase { type: "suggested_reviewers"; content: SuggestedReviewer[]; - created_at: string; } /** Artefact with `type: "dismissal"` — captures the user's rationale when suppressing a report. */ -export interface DismissalArtefact { - id: string; +export interface DismissalArtefact extends SignalReportArtefactBase { type: "dismissal"; content: DismissalContent; - created_at: string; } export interface DismissalContent { @@ -400,12 +402,9 @@ export interface DismissalContent { // Content shapes mirror products/signals/backend/artefact_schemas.py. /** Artefact with `type: "code_reference"` — a contiguous span of source lines. */ -export interface CodeReferenceArtefact { - id: string; +export interface CodeReferenceArtefact extends SignalReportArtefactBase { type: "code_reference"; content: CodeReferenceContent; - created_at: string; - updated_at?: string | null; } export interface CodeReferenceContent { @@ -417,12 +416,9 @@ export interface CodeReferenceContent { } /** Artefact with `type: "code_diff"` — a unified diff for a single file. */ -export interface CodeDiffArtefact { - id: string; +export interface CodeDiffArtefact extends SignalReportArtefactBase { type: "code_diff"; content: CodeDiffContent; - created_at: string; - updated_at?: string | null; } export interface CodeDiffContent { @@ -432,12 +428,9 @@ export interface CodeDiffContent { } /** Artefact with `type: "line_reference"` — a single source line callout (a point). */ -export interface LineReferenceArtefact { - id: string; +export interface LineReferenceArtefact extends SignalReportArtefactBase { type: "line_reference"; content: LineReferenceContent; - created_at: string; - updated_at?: string | null; } export interface LineReferenceContent { @@ -449,14 +442,9 @@ export interface LineReferenceContent { } /** Artefact with `type: "commit"` — one commit pushed in relation to the report. */ -export interface CommitArtefact { - id: string; +export interface CommitArtefact extends SignalReportArtefactBase { type: "commit"; content: CommitContent; - created_at: string; - updated_at?: string | null; - /** Task the artefact is attributed to (the agent session that pushed it), when known. */ - task_id?: string | null; } export interface CommitContent { @@ -468,12 +456,9 @@ export interface CommitContent { } /** Artefact with `type: "task_run"` — a reference to a `tasks.Task` run for the report. */ -export interface TaskRunArtefact { - id: string; +export interface TaskRunArtefact extends SignalReportArtefactBase { type: "task_run"; content: TaskRunArtefactContent; - created_at: string; - updated_at?: string | null; } export interface TaskRunArtefactContent { @@ -492,12 +477,9 @@ export interface TaskRunArtefactContent { } /** Artefact with `type: "note"` — a free-form note authored by an agent or by code. */ -export interface NoteArtefact { - id: string; +export interface NoteArtefact extends SignalReportArtefactBase { type: "note"; content: NoteContent; - created_at: string; - updated_at?: string | null; } export interface NoteContent { diff --git a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx index 9b88dfd02a..bba074eacc 100644 --- a/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx +++ b/packages/ui/src/features/inbox/components/detail/ArtefactLogList.tsx @@ -68,6 +68,20 @@ function languageFromPath(filePath: string): string { return filePath.split(".").pop()?.toLowerCase() ?? ""; } +/** + * Who produced the artefact: a user's name, "agent" for task-attributed writes, + * or null for system (pipeline) writes and pre-attribution rows. + */ +function attributionLabel(artefact: AnySignalReportArtefact): string | null { + if (artefact.created_by) { + return artefact.created_by.first_name?.trim() || artefact.created_by.email; + } + if (artefact.task_id) { + return "agent"; + } + return null; +} + // The generic `SignalReportArtefact` fallback carries `type: string`, so it stays // in every narrowed branch and breaks discriminated-union narrowing — the runtime // `type` dispatch is authoritative (content is set alongside type in the @@ -377,6 +391,7 @@ function ArtefactRow({ }) { const [showRaw, setShowRaw] = useState(false); const location = locationLabel(artefact); + const attribution = attributionLabel(artefact); return ( @@ -392,6 +407,11 @@ function ArtefactRow({ ) : null} + {attribution ? ( + + by {attribution} + + ) : null} {/* Dev-only escape hatch for inspecting the raw artefact payload. */} {import.meta.env.DEV ? (
diff --git a/packages/ui/src/features/inbox/components/PullRequestDetail.tsx b/packages/ui/src/features/inbox/components/PullRequestDetail.tsx index 4f2c780a88..460aa0bb14 100644 --- a/packages/ui/src/features/inbox/components/PullRequestDetail.tsx +++ b/packages/ui/src/features/inbox/components/PullRequestDetail.tsx @@ -6,6 +6,7 @@ import { import { parsePrUrl } from "@posthog/core/inbox/reportPresentation"; import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; +import { ReportActivitySection } from "@posthog/ui/features/inbox/components/detail/ReportActivitySection"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; import { InboxMetaSeparator } from "@posthog/ui/features/inbox/components/InboxMetaRow"; import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; @@ -98,6 +99,7 @@ function PullRequestDetailContent({ report }: { report: SignalReport }) { > + ); } diff --git a/packages/ui/src/features/inbox/components/ReportDetail.tsx b/packages/ui/src/features/inbox/components/ReportDetail.tsx index 4dd1f12068..ae2f76bb7e 100644 --- a/packages/ui/src/features/inbox/components/ReportDetail.tsx +++ b/packages/ui/src/features/inbox/components/ReportDetail.tsx @@ -1,20 +1,16 @@ import { - ClockCounterClockwiseIcon, CopyIcon, FileTextIcon, MagnifyingGlassIcon, } from "@phosphor-icons/react"; import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; -import { ArtefactLogList } from "@posthog/ui/features/inbox/components/detail/ArtefactLogList"; +import { ReportActivitySection } from "@posthog/ui/features/inbox/components/detail/ReportActivitySection"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; import { InboxReportDetailGate } from "@posthog/ui/features/inbox/components/InboxReportDetailGate"; import { ReportDetailActions } from "@posthog/ui/features/inbox/components/ReportDetailActions"; import { ReportTasksSection } from "@posthog/ui/features/inbox/components/ReportTasksSection"; -import { RightColumnSection } from "@posthog/ui/features/inbox/components/RightColumnSection"; import { SuggestedReviewersSection } from "@posthog/ui/features/inbox/components/SuggestedReviewersSection"; -import { useInboxReportArtefacts } from "@posthog/ui/features/inbox/hooks/useInboxReports"; -import { Text } from "@radix-ui/themes"; import { toast } from "sonner"; interface ReportDetailProps { @@ -40,9 +36,6 @@ export function ReportDetail({ } function ReportDetailContent({ report }: { report: SignalReport }) { - const { data: artefactsResp } = useInboxReportArtefacts(report.id); - const artefacts = artefactsResp?.results ?? []; - const handleCopyLink = () => { const url = `${window.location.origin}/code/inbox/reports/${report.id}`; navigator.clipboard @@ -76,19 +69,7 @@ function ReportDetailContent({ report }: { report: SignalReport }) { > - {artefacts.length > 0 ? ( - - {artefacts.length} entr{artefacts.length === 1 ? "y" : "ies"} - - } - > - - - ) : null} + ); } diff --git a/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx b/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx new file mode 100644 index 0000000000..9aa1c5e0fc --- /dev/null +++ b/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx @@ -0,0 +1,32 @@ +import { ClockCounterClockwiseIcon } from "@phosphor-icons/react"; +import { ArtefactLogList } from "@posthog/ui/features/inbox/components/detail/ArtefactLogList"; +import { RightColumnSection } from "@posthog/ui/features/inbox/components/RightColumnSection"; +import { useInboxReportArtefacts } from "@posthog/ui/features/inbox/hooks/useInboxReports"; +import { Text } from "@radix-ui/themes"; + +/** + * The report's artefact log ("Activity"), shared by every report detail + * surface (reports, pull requests, runs) so the work log follows the report + * wherever it is rendered. Renders nothing while loading or when the report + * has no artefacts. + */ +export function ReportActivitySection({ reportId }: { reportId: string }) { + const { data: artefactsResp } = useInboxReportArtefacts(reportId); + const artefacts = artefactsResp?.results ?? []; + + if (artefacts.length === 0) return null; + + return ( + + {artefacts.length} entr{artefacts.length === 1 ? "y" : "ies"} + + } + > + + + ); +} From b5b31d0d61eec6b0a713ad4659818539374647d5 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Fri, 12 Jun 2026 13:34:08 +0300 Subject: [PATCH 13/13] fix(inbox): keep the report activity log fresh while open The desktop app's global staleTime (5 min) meant agent-appended artefacts didn't appear until a hard refresh. The activity log is a live work record, so override with a short staleTime and gentle polling while mounted. Generated-By: PostHog Code Task-Id: f65424ca-a7fe-49f5-9f75-7a3cf004b417 --- .../inbox/components/detail/ReportActivitySection.tsx | 8 +++++++- packages/ui/src/features/inbox/hooks/useInboxReports.ts | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx b/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx index 9aa1c5e0fc..09b966df88 100644 --- a/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx +++ b/packages/ui/src/features/inbox/components/detail/ReportActivitySection.tsx @@ -11,7 +11,13 @@ import { Text } from "@radix-ui/themes"; * has no artefacts. */ export function ReportActivitySection({ reportId }: { reportId: string }) { - const { data: artefactsResp } = useInboxReportArtefacts(reportId); + // The log is a live work record — agents append artefacts while the report is + // open, so don't let the app-wide 5-minute staleTime sit on it. Poll gently + // while mounted. + const { data: artefactsResp } = useInboxReportArtefacts(reportId, { + staleTime: 10_000, + refetchInterval: 20_000, + }); const artefacts = artefactsResp?.results ?? []; if (artefacts.length === 0) return null; diff --git a/packages/ui/src/features/inbox/hooks/useInboxReports.ts b/packages/ui/src/features/inbox/hooks/useInboxReports.ts index 7049162963..97cf8b8922 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReports.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxReports.ts @@ -198,6 +198,7 @@ export function useInboxReportArtefacts( options?: { enabled?: boolean; staleTime?: number; + refetchInterval?: number; refetchOnWindowFocus?: boolean; }, ) {