diff --git a/.claude/skills/quill-code/SKILL.md b/.claude/skills/quill-code/SKILL.md new file mode 100644 index 0000000000..a61c9429b1 --- /dev/null +++ b/.claude/skills/quill-code/SKILL.md @@ -0,0 +1,93 @@ +--- +name: quill-code +description: Edit the @posthog/quill design system locally and consume the change in this repo (posthog-code) before it is published to npm. Use when changing quill components/primitives/tokens, when a quill change must be tested inside the Code app, or when the user mentions quill, the design system, the .local-quill tarball, or the @posthog/quill pnpm override. +--- + +# quill-code + +`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose +source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an +unpublished quill change inside this repo (posthog-code), you build quill, pack it to a +tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary +local-dev** state — revert before merging (see below). + +## Quill layout (where to edit) + +`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It +contains sub-packages, each a layer of the design system: + +- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …) +- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric) +- `packages/blocks/src` — **product-level blocks** (e.g. `ExperimentCard`). Add the + file here and export it from `packages/blocks/src/index.ts`. +- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`. + +A new export in any sub-package flows to `@posthog/quill` automatically on build. + +## The loop (every quill change) + +1. Edit/add the component in the right sub-package (above) and export it from that + package's `src/index.ts`. +2. Re-sync into this repo manually — build → pack → point the override → reinstall: + + ```bash + QUILL_DIR="${QUILL_DIR:-../posthog/packages/quill/packages/quill}" + + # a. Build the WHOLE quill workspace (two levels up from the aggregate), so the + # sub-packages rebuild BEFORE the aggregate bundles them. + ( cd "$QUILL_DIR/../.." && pnpm build ) + + # b. Pack into .local-quill/ under a UNIQUE filename. pnpm pins a tarball by + # integrity, so a stable name caches stale across re-syncs — drop old local + # tarballs first, then rename the packed file to a unique local name. + rm -f .local-quill/posthog-quill-local-*.tgz + ( cd "$QUILL_DIR" && npm pack --pack-destination "$(git rev-parse --show-toplevel)/.local-quill" ) + mv .local-quill/posthog-quill-[0-9]*.tgz ".local-quill/posthog-quill-local-$(git rev-parse --short HEAD)-$$.tgz" + + # c. Point the override at the new tarball, then reinstall. + # Edit pnpm-workspace.yaml so overrides['@posthog/quill'] = file:./.local-quill/ + pnpm install + ``` + + > Building only the aggregate (`packages/quill/packages/quill`) re-bundles the + > sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are + > silently dropped. Always build at the workspace root. +3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1. + +After every quill edit you **must** re-run the sync — the app consumes the tarball, not +the quill source, so unsynced edits are invisible here. + +If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`. + +## Why a tarball, not `link:` + +`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types, +colliding with this repo's **React 19** (dual-React → broken typecheck + +invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped +against React 19. The filename is **content-hashed** because pnpm pins a tarball by +integrity, so a stable filename gets cached stale across re-syncs. + +## The override (what the script rewrites) + +In `pnpm-workspace.yaml`, under `overrides:`: + +```yaml +'@posthog/quill': file:./.local-quill/posthog-quill-local-.tgz +``` + +There is also a permanent pin you should leave alone: + +```yaml +'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove +``` + +## Reverting (before merge) + +The override is local-dev only. Once the quill change is published to npm: + +1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`) + to the published version. +2. Restore the override line to point back at the catalog, or remove the `file:` override. +3. `pnpm install`. + +Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs. diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index f006547cc7..cfbdd290b1 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -96,6 +96,26 @@ snapshots: hash: v1.k4693efd2.b76d39ed8142d4449cb5befcde538bb11a6bffcbed66e81bdc3aa8e2746b7e06.GSIfBsgE-3RYuqlLUpluZ87C6-KjBUaTj2eu_a5elZ8 autoresearch-runtime-stats--with-context-usage--light: hash: v1.k4693efd2.18889b5eda942cd19b0f7be00a87925d632529ca4111fac180d6339f7d34d1ca.X3s9IXrx2-TdQaJEjf1td3hwvNzeIp3Xq3uQh9Gwt5A + billing-usagemeter--exceeded--dark: + hash: v1.k4693efd2.dc026c5cace4e44c4cc713b95654e278d7bf0439a4862e9ceea39a033974815d.m5Be4dAOwwmt-UlEnhbhrKiym1rlGUiGZ4oyUonUoVg + billing-usagemeter--exceeded--light: + hash: v1.k4693efd2.850bf1e451a18981d9e3be653837dd282c6be289ac9c0826ff6a981ac67e1e38.1GwpfJ_4dUlMO7TXp8AOM5YmJuItGGB50ihKkkeIFgg + billing-usagemeter--free-tier--dark: + hash: v1.k4693efd2.164d54cac22cfa05dbbf25fae1a62e77c496b1981dcaff6b8e1ab07fc802f5e0.Gh75uI1vHbGWAgKun41BYoX6LZBYX_FVI7Qu-rFlDV4 + billing-usagemeter--free-tier--light: + hash: v1.k4693efd2.5e220fe66fac4c54b4cb9f5ac6ec1054e0b709374fee2c58fdba782f5b2888df.W5la88YA6uOSc1tg30raKaRy12plPGxXl5o7uowL2gc + billing-usagemeter--subscribed-past-included--dark: + hash: v1.k4693efd2.ac987b7184aaa4f8895d9b75c6b7e1177efcb9a21cca0668caf533ee0d5eaab3.2Mq2GLXV83ebM8LKhTQY8csv6BeskWv5yPu8NSAQ20Y + billing-usagemeter--subscribed-past-included--light: + hash: v1.k4693efd2.0c4a37beaceb37baab57e6da98321b236c20127731fd5eaeb61677de4580c204.fZ2eDGlkEijZjHQyatiHtP39EFYT6EO1Vn_f8Tz7b0E + billing-usagemeter--subscribed-with-breakdown--dark: + hash: v1.k4693efd2.f74f8f76e51ee14e427423e1757859e3f001b13e6a43d1e7310b5b0cbbb570b5.eMwK2csA86oBarnAya99W87MfP6Z6PMfdjwbCmnPrw4 + billing-usagemeter--subscribed-with-breakdown--light: + hash: v1.k4693efd2.1335dba8bf16980c987882641619e114584343ec50bb69b3c01e63507a13756b._v7_6tsVMmW3WixSeFN-puHQVeIxf9AAGxFZ8Ni97YQ + billing-usagemeter--zero-spend-limit--dark: + hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw + billing-usagemeter--zero-spend-limit--light: + hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c components-permissions-permissionselector--create-new-file--dark: hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos components-permissions-permissionselector--create-new-file--light: diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts index 70c4653479..e8bbaefcb8 100644 --- a/apps/code/src/renderer/desktop-contributions.ts +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -1,6 +1,7 @@ import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module"; import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.module"; import { billingCoreModule } from "@posthog/core/billing/billing.module"; +import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; import { inboxCoreModule } from "@posthog/core/inbox/inbox.module"; import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module"; import { localMcpCoreModule } from "@posthog/core/local-mcp/local-mcp.module"; @@ -37,6 +38,7 @@ export function registerDesktopContributions(): void { autoresearchCoreModule, billingUiModule, billingCoreModule, + taskThreadCoreModule, browserTabsUiModule, cloneUiModule, connectivityUiModule, diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index d871b48534..6d15ba6884 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -1,5 +1,6 @@ import "reflect-metadata"; import { TypedContainer } from "@inversifyjs/strongly-typed"; +import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module"; import { setRootContainer } from "@posthog/di/container"; import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger"; import { @@ -96,4 +97,6 @@ container.bind(MCP_SANDBOX_PROXY_URL).toConstantValue(() => { return sandboxProxyUrl; }); +container.load(taskThreadCoreModule); + setRootContainer(container); diff --git a/packages/agent/src/adapters/claude/session/rtk.ts b/packages/agent/src/adapters/claude/session/rtk.ts index c609d7ee4d..0b7d256826 100644 --- a/packages/agent/src/adapters/claude/session/rtk.ts +++ b/packages/agent/src/adapters/claude/session/rtk.ts @@ -15,7 +15,8 @@ import { gitSubcommand } from "../git-command"; // Commands RTK compresses faithfully and that have no side effects, so wrapping // them changes only how much output reaches the model, never what runs. -const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); +// Exported so the instruction-level Codex guidance advertises the same set. +export const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // Git subcommands whose output is worth compressing and that RTK handles // faithfully. The criterion is compressible output, NOT read-only: RTK never @@ -23,7 +24,8 @@ const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]); // `git reflog expire`) still executes its write — its output is just shorter. // Excludes commit/push: negligible output to compress, and the cloud // signed-commit guard keys on a leading `git` token that `rtk git …` would hide. -const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ +// Exported so the instruction-level Codex guidance advertises the same set. +export const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ "status", "diff", "log", @@ -44,7 +46,8 @@ const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([ // wrapping only its head would change the meaning of the rest. const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/; -function shQuote(value: string): string { +// Exported so the instruction-level Codex guidance quotes the prefix the same way. +export function shQuote(value: string): string { if (/^[\w./-]+$/.test(value)) return value; return `'${value.replace(/'/g, `'\\''`)}'`; } diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 3e567b09c7..23355aee71 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -1961,9 +1961,23 @@ describe("CodexAppServerAgent", () => { } as unknown as PromptRequest); // The single turn/completed resolves both the original and the folded prompt. + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 45, + inputTokens: 30, + cachedInputTokens: 5, + outputTokens: 10, + }, + }, + }); stub.emit("turn/completed", { turn: { status: "completed" } }); - expect((await first).stopReason).toBe("end_turn"); - expect((await second).stopReason).toBe("end_turn"); + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(firstResult).toMatchObject({ + stopReason: "end_turn", + usage: { totalTokens: 45 }, + }); + expect(secondResult).toEqual({ stopReason: "end_turn" }); const steer = stub.requests.find((r) => r.method === "turn/steer"); expect(steer?.params).toMatchObject({ @@ -2126,7 +2140,19 @@ describe("CodexAppServerAgent", () => { }, }); stub.emit("turn/completed", { turn: { status: "completed" } }); - await done; + const result = await done; + + expect(result).toEqual({ + stopReason: "end_turn", + usage: { + inputTokens: 60, + outputTokens: 30, + cachedReadTokens: 10, + cachedWriteTokens: 0, + thoughtTokens: 5, + totalTokens: 100, + }, + }); const turnComplete = extNotifications.find( (n) => n.method === "_posthog/turn_complete", @@ -2818,6 +2844,16 @@ describe("CodexAppServerAgent", () => { text: "The implementation plan is ready.", }, }); + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 30, + inputTokens: 20, + outputTokens: 10, + reasoningOutputTokens: 2, + }, + }, + }); stub.emit("turn/completed", { turn: { id: "turn_1", status: "completed" }, }); @@ -2826,10 +2862,31 @@ describe("CodexAppServerAgent", () => { await waitUntil( () => stub.requests.filter((r) => r.method === "turn/start").length >= 2, ); + stub.emit("thread/tokenUsage/updated", { + tokenUsage: { + last: { + totalTokens: 50, + inputTokens: 35, + cachedInputTokens: 5, + outputTokens: 10, + reasoningOutputTokens: 3, + }, + }, + }); stub.emit("turn/completed", { turn: { id: "turn_2", status: "completed" }, }); - expect((await done).stopReason).toBe("end_turn"); + expect(await done).toEqual({ + stopReason: "end_turn", + usage: { + inputTokens: 55, + outputTokens: 20, + cachedReadTokens: 5, + cachedWriteTokens: 0, + thoughtTokens: 5, + totalTokens: 80, + }, + }); // The approval renders as the plan-approval UI (switch_mode + the plan text). expect(permissionRequests).toHaveLength(1); diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 4c4035a53b..8b1ce61b89 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -52,7 +52,6 @@ import { } from "./app-server-client"; import { handleServerRequest } from "./approvals"; import { - type AccumulatedUsage, buildSdkSessionParams, buildTurnCompleteParams, buildUsageBreakdownParams, @@ -162,6 +161,31 @@ function parseGoalCommand(prompt: PromptRequest["prompt"]): GoalCommand | null { } } +function mergePromptUsage( + left: PromptResponse["usage"], + right: PromptResponse["usage"], +): PromptResponse["usage"] { + if (!left) return right; + if (!right) return left; + return { + inputTokens: left.inputTokens + right.inputTokens, + outputTokens: left.outputTokens + right.outputTokens, + cachedReadTokens: + (left.cachedReadTokens ?? 0) + (right.cachedReadTokens ?? 0), + cachedWriteTokens: + (left.cachedWriteTokens ?? 0) + (right.cachedWriteTokens ?? 0), + thoughtTokens: (left.thoughtTokens ?? 0) + (right.thoughtTokens ?? 0), + totalTokens: left.totalTokens + right.totalTokens, + }; +} + +function mergePromptResponses( + left: PromptResponse, + right: PromptResponse, +): PromptResponse { + return { ...right, usage: mergePromptUsage(left.usage, right.usage) }; +} + // The native app-server owns its config; BaseAcpAgent only calls dispose() on this. class NoopSettingsManager implements BaseSettingsManager { constructor(private cwd: string) {} @@ -219,7 +243,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** The in-flight turn's , streamed or completed (drives the implement handoff). */ private planProposal?: { itemId: string; text: string }; /** Idle signal deferred while the plan handoff keeps this prompt busy. */ - private deferredTurnComplete?: { usage: AccumulatedUsage }; + private deferredTurnComplete?: { usage: PromptResponse["usage"] }; /** Settles the pending plan-approval race on cancel/close/preempting prompt. */ private planHandoffCancel?: () => void; private readonly mcp = new McpManager(); @@ -727,15 +751,16 @@ export class CodexAppServerAgent extends BaseAcpAgent { return undefined; }); this.turns.onSteered(steerRes?.turnId); - return { stopReason: await this.turns.awaitCompletion() }; + const response = await this.turns.awaitCompletion(); + return { stopReason: response.stopReason }; } if (this.turns.isPending) { // A turn is pending but has no turnId yet, so we can't steer; fail fast. throw new Error("prompt() called while a turn is already in progress"); } - const stopReason = await this.runTurn(input); - return { stopReason: await this.maybeOfferPlanImplementation(stopReason) }; + const response = await this.runTurn(input); + return this.maybeOfferPlanImplementation(response); } private async handleGoalCommand(command: GoalCommand): Promise { @@ -850,7 +875,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } /** Start one codex turn and await its completion. */ - private async runTurn(input: CodexUserInput[]): Promise { + private async runTurn(input: CodexUserInput[]): Promise { this.lastAgentMessage = ""; this.resetUsage(); this.planProposal = undefined; @@ -895,12 +920,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { * back into another plan turn, whose revised plan prompts again. */ private async maybeOfferPlanImplementation( - stopReason: StopReason, - ): Promise { - let reason = stopReason; + response: PromptResponse, + ): Promise { + let result = response; try { while ( - reason === "end_turn" && + result.stopReason === "end_turn" && this.config.mode === "plan" && this.planProposal && !this.session.cancelled @@ -911,7 +936,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { // Re-check after the await: a cancel that raced the response wins, so a // late accept can never start implementation on a cancelled prompt. if (this.session.cancelled) { - reason = "cancelled"; + result = { ...result, stopReason: "cancelled" }; break; } // A picker change while approval was open owns the mode. Never let a @@ -921,19 +946,25 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.config.setOption("mode", outcome.mode); this.emitCurrentMode(outcome.mode); this.emitConfigOptions(); - reason = await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE); + result = mergePromptResponses( + result, + await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE), + ); break; } if (outcome.kind === "feedback") { - reason = await this.runFollowUpTurn(outcome.feedback); + result = mergePromptResponses( + result, + await this.runFollowUpTurn(outcome.feedback), + ); continue; } break; } } finally { - await this.flushDeferredTurnComplete(reason); + await this.flushDeferredTurnComplete(result.stopReason); } - return reason; + return result; } /** @@ -949,7 +980,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { } /** Run an adapter-initiated turn, echoed as a user message like a host prompt. */ - private async runFollowUpTurn(text: string): Promise { + private async runFollowUpTurn(text: string): Promise { this.broadcastUserInput([{ type: "text", text }]); return this.runTurn(toCodexInput([{ type: "text", text }])); } @@ -1448,7 +1479,10 @@ export class CodexAppServerAgent extends BaseAcpAgent { await this.emitTurnCompleteSignal(reason, usage); await this.emitUsageBreakdown(contextUsed); } - pending.resolve(reason); + pending.resolve({ + stopReason: reason, + ...(usage ? { usage } : {}), + }); } /** Whether maybeOfferPlanImplementation will run for a turn that ended this way. */ @@ -1464,7 +1498,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** Emit the cloud idle signal `_posthog/turn_complete` (only with a taskRunId). */ private async emitTurnCompleteSignal( reason: StopReason, - usage: AccumulatedUsage, + usage: PromptResponse["usage"], ): Promise { if (!this.sessionId || !this.taskRunId) return; await this.client diff --git a/packages/agent/src/adapters/codex-app-server/ext-notifications.ts b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts index a692eaa421..f397285f21 100644 --- a/packages/agent/src/adapters/codex-app-server/ext-notifications.ts +++ b/packages/agent/src/adapters/codex-app-server/ext-notifications.ts @@ -50,13 +50,12 @@ export interface TurnCompleteParams { usage: TurnCompleteUsage; } -/** The four component counts the caller accumulates; total is computed here. */ -export interface AccumulatedUsage { - inputTokens: number; - outputTokens: number; - cachedReadTokens: number; - cachedWriteTokens: number; -} +type TurnCompleteUsageInput = { + inputTokens?: number | null; + outputTokens?: number | null; + cachedReadTokens?: number | null; + cachedWriteTokens?: number | null; +}; /** * `_posthog/turn_complete` — fired when a prompt turn finishes. `totalTokens` is the @@ -65,21 +64,22 @@ export interface AccumulatedUsage { export function buildTurnCompleteParams( sessionId: string, stopReason: StopReason, - usage: AccumulatedUsage, + usage?: TurnCompleteUsageInput | null, ): TurnCompleteParams { + const inputTokens = usage?.inputTokens ?? 0; + const outputTokens = usage?.outputTokens ?? 0; + const cachedReadTokens = usage?.cachedReadTokens ?? 0; + const cachedWriteTokens = usage?.cachedWriteTokens ?? 0; return { sessionId, stopReason, usage: { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - cachedReadTokens: usage.cachedReadTokens, - cachedWriteTokens: usage.cachedWriteTokens, + inputTokens, + outputTokens, + cachedReadTokens, + cachedWriteTokens, totalTokens: - usage.inputTokens + - usage.outputTokens + - usage.cachedReadTokens + - usage.cachedWriteTokens, + inputTokens + outputTokens + cachedReadTokens + cachedWriteTokens, }, }; } diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts index 9e7398ffe0..e8d1f6ab8f 100644 --- a/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.test.ts @@ -18,8 +18,8 @@ describe("TurnController", () => { expect(turns.activeTurnId).toBeUndefined(); expect(turns.claim()).toBeUndefined(); - pending?.resolve("end_turn"); - await expect(completion).resolves.toBe("end_turn"); + pending?.resolve({ stopReason: "end_turn" }); + await expect(completion).resolves.toEqual({ stopReason: "end_turn" }); }); it("finishPrompt for an older turn does not wipe a newer turn's pending state", async () => { @@ -36,15 +36,15 @@ describe("TurnController", () => { turns.onStarted("turn-b"); // Turn A's prompt() resolves and its finally runs; it must not clear turn B. - claimedA?.resolve("end_turn"); - await expect(a.completion).resolves.toBe("end_turn"); + claimedA?.resolve({ stopReason: "end_turn" }); + await expect(a.completion).resolves.toEqual({ stopReason: "end_turn" }); turns.finishPrompt(a.turn); expect(turns.isRunning).toBe(true); const claimedB = turns.claim(); expect(claimedB).toBeDefined(); - claimedB?.resolve("end_turn"); - await expect(b.completion).resolves.toBe("end_turn"); + claimedB?.resolve({ stopReason: "end_turn" }); + await expect(b.completion).resolves.toEqual({ stopReason: "end_turn" }); }); it("finishPrompt with the current turn token clears the pending slot", () => { @@ -84,7 +84,7 @@ describe("TurnController", () => { turns.markInterrupted(); turns.close("cancelled"); - await expect(completion).resolves.toBe("cancelled"); + await expect(completion).resolves.toEqual({ stopReason: "cancelled" }); expect(turns.isPending).toBe(false); expect(turns.activeTurnId).toBeUndefined(); expect(turns.shouldDropCompletion("turn-1")).toBe(false); diff --git a/packages/agent/src/adapters/codex-app-server/turn-controller.ts b/packages/agent/src/adapters/codex-app-server/turn-controller.ts index 350ba0b1ba..e6f6483a9a 100644 --- a/packages/agent/src/adapters/codex-app-server/turn-controller.ts +++ b/packages/agent/src/adapters/codex-app-server/turn-controller.ts @@ -1,7 +1,7 @@ -import type { StopReason } from "@agentclientprotocol/sdk"; +import type { PromptResponse, StopReason } from "@agentclientprotocol/sdk"; interface PendingTurn { - resolve: (reason: StopReason) => void; + resolve: (response: PromptResponse) => void; reject: (err: Error) => void; } @@ -13,13 +13,13 @@ interface PendingTurn { export class TurnController { private turnId?: string; private pending?: PendingTurn; - private completion?: Promise; + private completion?: Promise; private generation = 0; private readonly cancelled = new Set(); - begin(): { completion: Promise; turn: number } { + begin(): { completion: Promise; turn: number } { const turn = ++this.generation; - this.completion = new Promise((resolve, reject) => { + this.completion = new Promise((resolve, reject) => { this.pending = { resolve, reject }; }); return { completion: this.completion, turn }; @@ -49,8 +49,8 @@ export class TurnController { } /** Await the in-flight turn's completion (the steer path reuses the original). */ - awaitCompletion(): Promise { - return this.completion ?? Promise.resolve("end_turn"); + awaitCompletion(): Promise { + return this.completion ?? Promise.resolve({ stopReason: "end_turn" }); } /** Atomically claim the pending turn (clears the slot + turnId synchronously), or undefined if already claimed. */ @@ -96,7 +96,7 @@ export class TurnController { /** Resolve and clear everything on session close. */ close(reason: StopReason): void { this.turnId = undefined; - this.pending?.resolve(reason); + this.pending?.resolve({ stopReason: reason }); this.pending = undefined; this.completion = undefined; this.cancelled.clear(); diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts index aba636c727..d8c579bd18 100644 --- a/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.test.ts @@ -46,6 +46,8 @@ describe("UsageTracker", () => { outputTokens: 80, cachedReadTokens: 50, cachedWriteTokens: 0, + thoughtTokens: 20, + totalTokens: 500, }); }); @@ -54,7 +56,7 @@ describe("UsageTracker", () => { const update = tracker.ingest(payload({ last: undefined })); expect(update?.used).toBe(1200); - expect(tracker.perTurnUsage().inputTokens).toBe(900); + expect(tracker.perTurnUsage()?.inputTokens).toBe(900); }); it("derives `used` from inputTokens when totalTokens is absent (same order as the gauge)", () => { @@ -78,17 +80,12 @@ describe("UsageTracker", () => { expect(tracker.contextTokens()).toBe(500); }); - it("resetForTurn zeroes the per-turn view so a token-less turn reports 0, not stale data", () => { + it("resetForTurn clears stale per-turn usage", () => { const tracker = new UsageTracker(); tracker.ingest(payload()); tracker.resetForTurn(); expect(tracker.contextTokens()).toBeUndefined(); - expect(tracker.perTurnUsage()).toEqual({ - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, - }); + expect(tracker.perTurnUsage()).toBeUndefined(); }); }); diff --git a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts index 154d7d1159..fd107a7c13 100644 --- a/packages/agent/src/adapters/codex-app-server/usage-tracker.ts +++ b/packages/agent/src/adapters/codex-app-server/usage-tracker.ts @@ -1,8 +1,8 @@ +import type { Usage } from "@agentclientprotocol/sdk"; import { type ContextBreakdownBaseline, emptyBaseline, } from "../claude/context-breakdown"; -import type { AccumulatedUsage } from "./ext-notifications"; import { readTokenUsage } from "./token-usage"; /** The live `_posthog/usage_update` fields (context-window occupancy). */ @@ -25,7 +25,7 @@ export interface UsageUpdate { */ export class UsageTracker { private baseline: ContextBreakdownBaseline = emptyBaseline(); - private lastTurn?: AccumulatedUsage; + private lastTurn?: Usage; private contextUsed?: number; setBaseline(baseline: ContextBreakdownBaseline): void { @@ -36,7 +36,6 @@ export class UsageTracker { return this.baseline; } - /** Zero the per-turn view at turn start so a token-less turn reports 0. */ resetForTurn(): void { this.lastTurn = undefined; this.contextUsed = undefined; @@ -49,12 +48,17 @@ export class UsageTracker { const { context, used, size } = reading; // Drives the per-source breakdown's "conversation" bucket on turn complete. this.contextUsed = used; + const inputTokens = context.inputTokens ?? 0; + const outputTokens = context.outputTokens ?? 0; + const cachedReadTokens = context.cachedInputTokens ?? 0; this.lastTurn = { - inputTokens: context.inputTokens ?? 0, - outputTokens: context.outputTokens ?? 0, - cachedReadTokens: context.cachedInputTokens ?? 0, - // codex's TokenUsageBreakdown has no cache-write field; 0 is authoritative. + inputTokens, + outputTokens, + cachedReadTokens, cachedWriteTokens: 0, + thoughtTokens: context.reasoningOutputTokens, + totalTokens: + context.totalTokens ?? inputTokens + outputTokens + cachedReadTokens, }; return { used, @@ -69,16 +73,8 @@ export class UsageTracker { }; } - /** Per-turn usage for `_posthog/turn_complete` — codex's `last`, not a delta. */ - perTurnUsage(): AccumulatedUsage { - return ( - this.lastTurn ?? { - inputTokens: 0, - outputTokens: 0, - cachedReadTokens: 0, - cachedWriteTokens: 0, - } - ); + perTurnUsage(): Usage | undefined { + return this.lastTurn ? { ...this.lastTurn } : undefined; } /** Live context occupancy (same derivation as the renderer gauge), or undefined pre-usage. */ diff --git a/packages/agent/src/adapters/rtk-guidance.test.ts b/packages/agent/src/adapters/rtk-guidance.test.ts new file mode 100644 index 0000000000..1664ca1a1c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.test.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, +} from "./claude/session/rtk"; +import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance"; + +describe("rtk guidance for codex", () => { + let dir: string; + let binary: string; + + beforeAll(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-")); + binary = path.join(dir, "rtk"); + fs.writeFileSync(binary, "#!/bin/sh\n"); + }); + + afterAll(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + describe("buildRtkGuidance", () => { + // The guidance must advertise exactly the Claude hook's eligibility sets, + // so the token-usage cohorts stay comparable across adapters. + test("advertises every command the Claude hook rewrites", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + for (const command of RTK_PLAIN_COMMANDS) { + expect(guidance).toContain(command); + } + for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) { + expect(guidance).toContain(sub); + } + }); + + test("uses the resolved binary path in the examples", () => { + const guidance = buildRtkGuidance("/usr/local/bin/rtk"); + expect(guidance).toContain("`/usr/local/bin/rtk git status`"); + }); + + // A desktop install can resolve a path with spaces; unquoted it would + // split into multiple shell tokens and every guided command would fail. + test("shell-quotes a binary path containing spaces", () => { + const guidance = buildRtkGuidance("/Apps/PostHog Code/rtk"); + expect(guidance).toContain("`'/Apps/PostHog Code/rtk' git status`"); + expect(guidance).not.toContain("`/Apps/PostHog Code/rtk git status`"); + }); + + // Parity with the Claude hook's exclusion: prefixing commit/push would + // hide the leading `git` token from the cloud signed-commit guard. + test("forbids prefixing git commit and git push", () => { + const guidance = buildRtkGuidance("rtk"); + expect(guidance).toContain("Never prefix `git commit`, `git push`"); + }); + }); + + describe("appendRtkGuidanceForCodex", () => { + test("appends guidance when rtk is on PATH", () => { + const result = appendRtkGuidanceForCodex("base instructions", { + PATH: dir, + }); + expect(result.startsWith("base instructions\n\n")).toBe(true); + expect(result).toContain("rtk command-output compression"); + expect(result).toContain(binary); + }); + + // POSTHOG_RTK=0 is set per run from the cloud kill-switch flag; it must + // silence the guidance too, which is why the gate is resolveRtkPrefix + // rather than detectRtkBinary. + test.each([["0"], ["false"]])( + "returns instructions unchanged when POSTHOG_RTK is %s", + (value) => { + expect( + appendRtkGuidanceForCodex("base instructions", { + POSTHOG_RTK: value, + PATH: dir, + }), + ).toBe("base instructions"); + }, + ); + + test("returns instructions unchanged when rtk is not installed", () => { + expect( + appendRtkGuidanceForCodex("base instructions", { + PATH: "/nonexistent", + }), + ).toBe("base instructions"); + }); + + test("does not leave a leading separator when instructions are empty", () => { + const result = appendRtkGuidanceForCodex("", { PATH: dir }); + expect(result.startsWith("## rtk")).toBe(true); + }); + }); +}); diff --git a/packages/agent/src/adapters/rtk-guidance.ts b/packages/agent/src/adapters/rtk-guidance.ts new file mode 100644 index 0000000000..962913a32c --- /dev/null +++ b/packages/agent/src/adapters/rtk-guidance.ts @@ -0,0 +1,59 @@ +import { + GIT_COMPRESSIBLE_SUBCOMMANDS, + RTK_PLAIN_COMMANDS, + resolveRtkPrefix, + shQuote, +} from "./claude/session/rtk"; + +/** + * Instruction-level RTK integration for Codex sessions. + * + * The Claude adapter routes eligible commands through RTK deterministically + * with a PreToolUse hook that rewrites the Bash input. Codex executes shell + * commands internally over JSON-RPC and its app-server protocol has no + * command-rewrite channel — the adapter can only approve or deny — so the + * only integration point is the developer instructions: tell the model to + * prefix eligible commands itself. + * + * The advertised command set and rules mirror the Claude hook exactly + * (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only, + * never commit/push), so token-usage cohorts stay comparable across adapters. + */ +export function buildRtkGuidance(rtkPrefix: string): string { + // Same quoting as the Claude rewrite hook: a resolved path containing + // spaces must stay one shell token in the commands the model copies. + const prefix = shQuote(rtkPrefix); + const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `"); + const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", "); + + return `## rtk command-output compression + +\`${prefix}\` is installed. It runs a command unchanged and compresses its output before you read it, so prefixed commands cost far less context. When you execute one of these as a single, bare command, prefix it with \`${prefix}\`: + +- \`${plainCommands}\` +- these git subcommands: ${gitSubcommands} + +Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`. + +Rules: +- Only prefix a single bare invocation. Never use it when the command is part of a pipe, uses \`&&\`, \`;\`, or redirection, or when another program parses the output — compression would corrupt what the consumer reads. +- Never prefix \`git commit\`, \`git push\`, or any other command not listed above. +- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`; +} + +/** + * Appends the RTK guidance to Codex developer instructions when an RTK binary + * is usable. Gated on `resolveRtkPrefix` — not `detectRtkBinary` — so the + * per-run `POSTHOG_RTK=0` opt-out (the cloud kill-switch flag) disables the + * guidance along with everything else. + */ +export function appendRtkGuidanceForCodex( + instructions: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const rtkPrefix = resolveRtkPrefix(env); + if (!rtkPrefix) return instructions; + return [instructions, buildRtkGuidance(rtkPrefix)] + .filter(Boolean) + .join("\n\n"); +} diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index a8a0b25d4e..140863f0da 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -45,6 +45,7 @@ import { classifyAgentError, isPromptTooLongError, } from "../adapters/error-classification"; +import { appendRtkGuidanceForCodex } from "../adapters/rtk-guidance"; import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME, SIGNED_MERGE_QUALIFIED_TOOL_NAME, @@ -3022,9 +3023,11 @@ export class AgentServer { private buildCodexInstructions( systemPrompt: string | { append: string }, ): string { - return typeof systemPrompt === "string" - ? systemPrompt - : systemPrompt.append; + const instructions = + typeof systemPrompt === "string" ? systemPrompt : systemPrompt.append; + // Codex has no command-rewrite hook (see rtk-guidance.ts), so RTK is + // adopted through the developer instructions instead. + return appendRtkGuidanceForCodex(instructions); } /** diff --git a/packages/core/src/billing/usageDisplay.test.ts b/packages/core/src/billing/usageDisplay.test.ts index d7c3f882fd..7ae0c2c050 100644 --- a/packages/core/src/billing/usageDisplay.test.ts +++ b/packages/core/src/billing/usageDisplay.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import type { UsageOutput } from "../usage/schemas"; import { + codeOrgSpendLimitUsd, codeUsageMeter, formatResetTime, + formatUsageBreakdown, formatUsdAmount, isCodeUsageFreeTier, isUsageExceeded, @@ -92,9 +94,32 @@ describe("codeUsageMeter", () => { percent: 25, exceeded: false, resetAt: "2026-06-01T00:00:00.000Z", + breakdown: { includedUsd: 20, spendLimitUsd: 30 }, }); }); + it("splits a default-settings subscribed limit into $20 included + $50 spend limit", () => { + const meter = codeUsageMeter({ + ...makeUsage(), + code_usage_subscribed: true, + ai_credits: { exhausted: false, used_usd: 5, limit_usd: 70 }, + }); + expect(meter).toMatchObject({ + kind: "dollars", + limitUsd: 70, + breakdown: { includedUsd: 20, spendLimitUsd: 50 }, + }); + }); + + it("keeps a free org's dollars meter breakdown-free — its limit is just the allowance", () => { + const meter = codeUsageMeter({ + ...makeUsage(), + code_usage_subscribed: false, + ai_credits: { exhausted: false, used_usd: 5, limit_usd: 20 }, + }); + expect(meter).toMatchObject({ kind: "dollars", breakdown: null }); + }); + it("marks the dollars meter exceeded from the org bucket and falls back to the sustained reset", () => { const meter = codeUsageMeter({ ...makeUsage(), @@ -134,6 +159,59 @@ describe("codeUsageMeter", () => { }); }); +describe("codeOrgSpendLimitUsd", () => { + const subscribedWithLimit = (limitUsd: number | null) => ({ + code_usage_subscribed: true, + ai_credits: { exhausted: false, used_usd: 0, limit_usd: limitUsd }, + }); + + it.each([ + ["default settings", 70, 50], + ["custom limit", 120.5, 100.5], + ["a $0 spend limit is a real answer", 20, 0], + ])("recovers the configured limit with %s", (_name, limitUsd, expected) => { + expect(codeOrgSpendLimitUsd(subscribedWithLimit(limitUsd))).toBe(expected); + }); + + it("returns null when the merged limit is below the allowance", () => { + expect(codeOrgSpendLimitUsd(subscribedWithLimit(15))).toBeNull(); + }); + + it("returns null without a limit number", () => { + expect(codeOrgSpendLimitUsd(subscribedWithLimit(null))).toBeNull(); + expect( + codeOrgSpendLimitUsd({ + code_usage_subscribed: true, + ai_credits: undefined, + }), + ).toBeNull(); + }); + + it("returns null for free, unknown, or missing orgs", () => { + expect( + codeOrgSpendLimitUsd({ + code_usage_subscribed: false, + ai_credits: { exhausted: false, used_usd: 0, limit_usd: 20 }, + }), + ).toBeNull(); + expect( + codeOrgSpendLimitUsd({ + code_usage_subscribed: undefined, + ai_credits: { exhausted: false, used_usd: 0, limit_usd: 70 }, + }), + ).toBeNull(); + expect(codeOrgSpendLimitUsd(null)).toBeNull(); + }); +}); + +describe("formatUsageBreakdown", () => { + it("phrases the merged limit as included + spend limit", () => { + expect(formatUsageBreakdown({ includedUsd: 20, spendLimitUsd: 50 })).toBe( + "$20 included + $50 org spend limit", + ); + }); +}); + describe("formatUsdAmount", () => { it.each([ [50, "$50"], diff --git a/packages/core/src/billing/usageDisplay.ts b/packages/core/src/billing/usageDisplay.ts index fcf22434c7..1207cf9d91 100644 --- a/packages/core/src/billing/usageDisplay.ts +++ b/packages/core/src/billing/usageDisplay.ts @@ -1,5 +1,7 @@ import type { UsageBucket, UsageOutput } from "../usage/schemas"; +export const CODE_INCLUDED_USAGE_USD = 20; + /** Confirmed free tier only — an absent `code_usage_subscribed` is unknown, never free. */ export function isCodeUsageFreeTier( usage: Pick | null | undefined, @@ -7,6 +9,23 @@ export function isCodeUsageFreeTier( return usage?.code_usage_subscribed === false; } +export function codeOrgSpendLimitUsd( + usage: + | Pick + | null + | undefined, +): number | null { + if (usage?.code_usage_subscribed !== true) return null; + const limitUsd = usage.ai_credits?.limit_usd; + if (limitUsd == null || limitUsd < CODE_INCLUDED_USAGE_USD) return null; + return Math.round((limitUsd - CODE_INCLUDED_USAGE_USD) * 100) / 100; +} + +export interface CodeUsageBreakdown { + includedUsd: number; + spendLimitUsd: number; +} + export type CodeUsageMeter = | { kind: "dollars"; @@ -15,6 +34,7 @@ export type CodeUsageMeter = percent: number; exceeded: boolean; resetAt: string; + breakdown: CodeUsageBreakdown | null; } | { kind: "bucket"; bucket: UsageBucket } | { kind: "hidden" }; @@ -32,6 +52,7 @@ export function codeUsageMeter( const usedUsd = usage.ai_credits?.used_usd; const limitUsd = usage.ai_credits?.limit_usd; if (usedUsd != null && limitUsd != null && limitUsd > 0) { + const spendLimitUsd = codeOrgSpendLimitUsd(usage); return { kind: "dollars", usedUsd, @@ -39,6 +60,10 @@ export function codeUsageMeter( percent: Math.min(100, Math.round((usedUsd / limitUsd) * 100)), exceeded: usage.ai_credits?.exhausted === true, resetAt: usage.billing_period_end ?? usage.sustained.reset_at, + breakdown: + spendLimitUsd != null + ? { includedUsd: CODE_INCLUDED_USAGE_USD, spendLimitUsd } + : null, }; } if (isCodeUsageFreeTier(usage)) { @@ -51,6 +76,10 @@ export function formatUsdAmount(amount: number): string { return Number.isInteger(amount) ? `$${amount}` : `$${amount.toFixed(2)}`; } +export function formatUsageBreakdown(breakdown: CodeUsageBreakdown): string { + return `${formatUsdAmount(breakdown.includedUsd)} included + ${formatUsdAmount(breakdown.spendLimitUsd)} org spend limit`; +} + export function isUsageExceeded(usage: UsageOutput): boolean { return ( usage.is_rate_limited || usage.sustained.exceeded || usage.burst.exceeded diff --git a/packages/core/src/canvas/channelFeed.test.ts b/packages/core/src/canvas/channelFeed.test.ts new file mode 100644 index 0000000000..ebe14b6f4c --- /dev/null +++ b/packages/core/src/canvas/channelFeed.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { taskFeedRunStatus } from "./channelFeed"; + +describe("taskFeedRunStatus", () => { + it.each(["queued", "in_progress"] as const)( + "preserves a cloud %s status even when local session activity has settled", + (status) => { + expect( + taskFeedRunStatus({ + status, + environment: "cloud", + }), + ).toBe(status); + }, + ); + + it("hides an unreliable non-terminal local status", () => { + expect( + taskFeedRunStatus({ status: "queued", environment: "local" }), + ).toBeNull(); + }); + + it("keeps a terminal local status", () => { + expect( + taskFeedRunStatus({ status: "completed", environment: "local" }), + ).toBe("completed"); + }); +}); diff --git a/packages/core/src/canvas/channelFeed.ts b/packages/core/src/canvas/channelFeed.ts new file mode 100644 index 0000000000..684253d656 --- /dev/null +++ b/packages/core/src/canvas/channelFeed.ts @@ -0,0 +1,23 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; + +const PERMANENT_CHANNEL_FEED_FAILURES = new Set([401, 403, 404]); + +export function shouldPollChannelFeed(error: unknown): boolean { + if (!error || typeof error !== "object" || !("status" in error)) return true; + const status = (error as { status?: unknown }).status; + return ( + typeof status !== "number" || !PERMANENT_CHANNEL_FEED_FAILURES.has(status) + ); +} + +export function taskFeedRunStatus({ + status, + environment, +}: { + status: TaskRunStatus | null | undefined; + environment: string | null | undefined; +}): TaskRunStatus | null { + if (!status) return null; + return environment === "cloud" || isTerminalStatus(status) ? status : null; +} diff --git a/packages/core/src/canvas/taskThread.module.ts b/packages/core/src/canvas/taskThread.module.ts new file mode 100644 index 0000000000..852ac4a0b7 --- /dev/null +++ b/packages/core/src/canvas/taskThread.module.ts @@ -0,0 +1,7 @@ +import { ContainerModule } from "inversify"; +import { TASK_THREAD_SERVICE, TaskThreadService } from "./taskThreadService"; + +export const taskThreadCoreModule = new ContainerModule(({ bind }) => { + bind(TaskThreadService).toSelf().inSingletonScope(); + bind(TASK_THREAD_SERVICE).toService(TaskThreadService); +}); diff --git a/packages/core/src/canvas/taskThreadService.test.ts b/packages/core/src/canvas/taskThreadService.test.ts new file mode 100644 index 0000000000..e8f4bd61a7 --- /dev/null +++ b/packages/core/src/canvas/taskThreadService.test.ts @@ -0,0 +1,66 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { TaskThreadMessage } from "@posthog/shared/domain-types"; +import { describe, expect, it, vi } from "vitest"; +import { TaskThreadService } from "./taskThreadService"; + +function message( + overrides: Partial = {}, +): TaskThreadMessage { + return { + id: "message-id", + task: "task-id", + content: "@agent investigate this", + created_at: "2026-07-16T00:00:00Z", + ...overrides, + }; +} + +function client(overrides: Partial = {}): PostHogAPIClient { + return overrides as PostHogAPIClient; +} + +describe("TaskThreadService", () => { + it("creates a message before forwarding it to the agent", async () => { + const createTaskThreadMessage = vi.fn().mockResolvedValue(message()); + const sendTaskThreadMessageToAgent = vi + .fn() + .mockResolvedValue( + message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }), + ); + const service = new TaskThreadService(); + + await service.postMessageToAgent( + client({ createTaskThreadMessage, sendTaskThreadMessageToAgent }), + "task-id", + "@agent investigate this", + ); + + expect(createTaskThreadMessage).toHaveBeenCalledWith( + "task-id", + "@agent investigate this", + ); + expect(sendTaskThreadMessageToAgent).toHaveBeenCalledWith( + "task-id", + "message-id", + ); + expect(createTaskThreadMessage.mock.invocationCallOrder[0]).toBeLessThan( + sendTaskThreadMessageToAgent.mock.invocationCallOrder[0], + ); + }); + + it("returns the created message when forwarding fails", async () => { + const sendError = new Error("No active run"); + const service = new TaskThreadService(); + + await expect( + service.postMessageToAgent( + client({ + createTaskThreadMessage: vi.fn().mockResolvedValue(message()), + sendTaskThreadMessageToAgent: vi.fn().mockRejectedValue(sendError), + }), + "task-id", + "@agent investigate this", + ), + ).resolves.toEqual({ message: message(), sendError }); + }); +}); diff --git a/packages/core/src/canvas/taskThreadService.ts b/packages/core/src/canvas/taskThreadService.ts new file mode 100644 index 0000000000..ca65a5d770 --- /dev/null +++ b/packages/core/src/canvas/taskThreadService.ts @@ -0,0 +1,31 @@ +import type { PostHogAPIClient } from "@posthog/api-client/posthog-client"; +import type { TaskThreadMessage } from "@posthog/shared/domain-types"; +import { injectable } from "inversify"; + +export const TASK_THREAD_SERVICE = Symbol.for( + "posthog.core.canvas.taskThreadService", +); + +export interface PostMessageToAgentResult { + message: TaskThreadMessage; + sendError: unknown | null; +} + +@injectable() +export class TaskThreadService { + async postMessageToAgent( + client: PostHogAPIClient, + taskId: string, + content: string, + ): Promise { + const message = await client.createTaskThreadMessage(taskId, content); + try { + return { + message: await client.sendTaskThreadMessageToAgent(taskId, message.id), + sendError: null, + }; + } catch (sendError) { + return { message, sendError }; + } + } +} diff --git a/packages/core/src/canvas/threadTimeline.test.ts b/packages/core/src/canvas/threadTimeline.test.ts new file mode 100644 index 0000000000..a3b3b355f3 --- /dev/null +++ b/packages/core/src/canvas/threadTimeline.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from "vitest"; +import { + buildThreadTimeline, + deriveThreadAgentStatus, + hasAgentMention, + normalizeAgentPromptText, + shouldSuspendThreadSession, +} from "./threadTimeline"; + +describe("hasAgentMention", () => { + it.each([ + ["at the start", "@agent investigate this", true], + ["after other text", "Could you @Agent check this?", true], + ["inside an email-like token", "person@agent.com", false], + ["as part of a longer handle", "@agents", false], + ["without a mention", "human-only note", false], + ])("detects an agent mention %s", (_name, content, expected) => { + expect(hasAgentMention(content)).toBe(expected); + }); +}); + +describe("normalizeAgentPromptText", () => { + it.each([ + [ + "forwarded thread comment", + "[Thread comment from Peter Kirkham] @agent which model are you?", + "which model are you?", + ], + ["direct prompt", "which model are you?", "which model are you?"], + [ + "direct prompt with mention", + "@agent which model are you?", + "which model are you?", + ], + ])("normalizes a %s", (_name, content, expected) => { + expect(normalizeAgentPromptText(content)).toBe(expected); + }); +}); + +describe("buildThreadTimeline", () => { + it("omits the session echo of a forwarded thread message", () => { + const timeline = buildThreadTimeline({ + prompts: [ + { + id: "prompt", + text: "[Thread comment from Peter Kirkham] @agent which model are you?", + timestamp: 200, + }, + ], + humanMessages: [ + { + id: "human", + content: "@agent which model are you?", + createdAt: "1970-01-01T00:00:00.100Z", + forwardedToAgent: true, + }, + ], + agentMessages: [], + }); + + expect(timeline.map((row) => row.kind)).toEqual(["human"]); + }); + + it("keeps a thread-comment prompt without a matching forwarded message", () => { + const timeline = buildThreadTimeline({ + prompts: [ + { + id: "prompt", + text: "[Thread comment from Peter Kirkham] @agent which model are you?", + timestamp: 200, + }, + ], + humanMessages: [], + agentMessages: [], + }); + + expect(timeline.map((row) => row.kind)).toEqual(["prompt"]); + }); + + it("interleaves prompts, human replies, and agent turns chronologically", () => { + const timeline = buildThreadTimeline({ + prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], + humanMessages: [ + { + id: "human", + content: "Reply", + createdAt: "1970-01-01T00:00:00.150Z", + }, + ], + agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], + }); + + expect(timeline.map((row) => row.kind)).toEqual([ + "prompt", + "human", + "agent", + ]); + }); + + it("keeps malformed timestamps at the end", () => { + const timeline = buildThreadTimeline({ + prompts: [{ id: "prompt", text: "Start", timestamp: 100 }], + humanMessages: [{ id: "human", content: "Reply", createdAt: "invalid" }], + agentMessages: [{ id: "agent", text: "Done", timestamp: 200 }], + }); + + expect(timeline.map((row) => row.kind)).toEqual([ + "prompt", + "agent", + "human", + ]); + }); +}); + +describe("deriveThreadAgentStatus", () => { + it.each([ + { + name: "returns no status before activity", + input: {}, + expected: null, + }, + { + name: "prioritizes failures", + input: { hasActivity: true, hasError: true, errorTitle: "Run failed" }, + expected: { phase: "error", label: "Run failed" }, + }, + { + name: "prioritizes pending permissions over active work", + input: { + hasActivity: true, + pendingPermissionCount: 1, + isPromptPending: true, + }, + expected: { phase: "needs_input", label: "Needs input" }, + }, + { + name: "reports active work", + input: { hasActivity: true, isPromptPending: true }, + expected: { phase: "active", label: "Working…" }, + }, + { + name: "returns no status after work settles", + input: { hasActivity: true }, + expected: null, + }, + ])("$name", ({ input, expected }) => { + expect(deriveThreadAgentStatus(input)).toEqual(expected); + }); +}); + +describe("shouldSuspendThreadSession", () => { + it("suspends a local runless task so reading cannot start work", () => { + expect( + shouldSuspendThreadSession({ + isCloud: false, + hasRun: false, + hasSession: false, + }), + ).toBe(true); + }); + + it.each([ + { isCloud: true, hasRun: false, hasSession: false }, + { isCloud: false, hasRun: true, hasSession: false }, + { isCloud: false, hasRun: false, hasSession: true }, + ])("keeps an existing or cloud session attached", (input) => { + expect(shouldSuspendThreadSession(input)).toBe(false); + }); +}); diff --git a/packages/core/src/canvas/threadTimeline.ts b/packages/core/src/canvas/threadTimeline.ts new file mode 100644 index 0000000000..a440d4e08d --- /dev/null +++ b/packages/core/src/canvas/threadTimeline.ts @@ -0,0 +1,144 @@ +export interface ThreadAgentMessage { + id: string; + text: string; + timestamp?: number; +} + +export interface ThreadHumanMessage { + id: string; + content: string; + createdAt: string; + forwardedToAgent?: boolean; + value?: T; +} + +export type ThreadTimelineRow = + | { kind: "prompt"; timestamp: number; message: ThreadAgentMessage } + | { kind: "agent"; timestamp: number; message: ThreadAgentMessage } + | { kind: "human"; timestamp: number; message: ThreadHumanMessage }; + +function validTimestamp(timestamp: number | undefined): number { + return timestamp !== undefined && Number.isFinite(timestamp) + ? timestamp + : Number.MAX_SAFE_INTEGER; +} + +function parsedTimestamp(timestamp: string): number { + const parsed = Date.parse(timestamp); + return Number.isFinite(parsed) ? parsed : Number.MAX_SAFE_INTEGER; +} + +export function buildThreadTimeline({ + prompts, + agentMessages, + humanMessages, +}: { + prompts: ThreadAgentMessage[]; + agentMessages: ThreadAgentMessage[]; + humanMessages: ThreadHumanMessage[]; +}): ThreadTimelineRow[] { + const forwardedHumanContent = new Set( + humanMessages + .filter((message) => message.forwardedToAgent) + .map((message) => normalizeAgentPromptText(message.content)), + ); + const visiblePrompts = prompts.filter( + (message) => + !isThreadCommentPrompt(message.text) || + !forwardedHumanContent.has(normalizeAgentPromptText(message.text)), + ); + + return [ + ...visiblePrompts.map( + (message): ThreadTimelineRow => ({ + kind: "prompt", + timestamp: validTimestamp(message.timestamp), + message, + }), + ), + ...humanMessages.map( + (message): ThreadTimelineRow => ({ + kind: "human", + timestamp: parsedTimestamp(message.createdAt), + message, + }), + ), + ...agentMessages.map( + (message): ThreadTimelineRow => ({ + kind: "agent", + timestamp: validTimestamp(message.timestamp), + message, + }), + ), + ].sort((left, right) => left.timestamp - right.timestamp); +} + +export type ThreadAgentPhase = "active" | "needs_input" | "error"; + +export interface ThreadAgentStatus { + phase: ThreadAgentPhase; + label: string; +} + +const AGENT_MENTION_PATTERN = /(^|\s)@agent\b/i; +const THREAD_COMMENT_ATTRIBUTION_PATTERN = + /^\[Thread comment from [^\]\r\n]+\]\s*/i; +const LEADING_AGENT_MENTION_PATTERN = /^@agent\b[\s:]*/i; + +export function hasAgentMention(content: string): boolean { + return AGENT_MENTION_PATTERN.test(content); +} + +export function normalizeAgentPromptText(content: string): string { + return content + .trim() + .replace(THREAD_COMMENT_ATTRIBUTION_PATTERN, "") + .replace(LEADING_AGENT_MENTION_PATTERN, "") + .trim(); +} + +function isThreadCommentPrompt(content: string): boolean { + return THREAD_COMMENT_ATTRIBUTION_PATTERN.test(content.trim()); +} + +export function deriveThreadAgentStatus({ + hasActivity = false, + hasError = false, + cloudStatus, + errorTitle, + pendingPermissionCount = 0, + isPromptPending = false, + isInitializing = false, +}: { + hasActivity?: boolean; + hasError?: boolean; + cloudStatus?: string | null; + errorTitle?: string | null; + pendingPermissionCount?: number; + isPromptPending?: boolean; + isInitializing?: boolean; +}): ThreadAgentStatus | null { + if (!hasActivity) return null; + if (hasError || cloudStatus === "failed") { + return { phase: "error", label: errorTitle ?? "Failed" }; + } + if (pendingPermissionCount > 0) { + return { phase: "needs_input", label: "Needs input" }; + } + if (isPromptPending || isInitializing) { + return { phase: "active", label: "Working…" }; + } + return null; +} + +export function shouldSuspendThreadSession({ + isCloud, + hasRun, + hasSession, +}: { + isCloud: boolean; + hasRun: boolean; + hasSession: boolean; +}): boolean { + return !isCloud && !hasRun && !hasSession; +} diff --git a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx index b2484be203..984ebee993 100644 --- a/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx +++ b/packages/ui/src/features/billing/UsageBillingAnnouncementModal.tsx @@ -1,5 +1,8 @@ import { ArrowSquareOut, CreditCard } from "@phosphor-icons/react"; -import { formatUsdAmount } from "@posthog/core/billing/usageDisplay"; +import { + codeOrgSpendLimitUsd, + formatUsdAmount, +} from "@posthog/core/billing/usageDisplay"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; import { useEffect } from "react"; @@ -22,12 +25,7 @@ export function UsageBillingAnnouncementModal() { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const { usage } = useUsage({ enabled: isOpen }); - // A free org's limit_usd is its included allocation (the first bullet's - // $20), not a spend limit — the org-specific line is for subscribed orgs. - const limitUsd = - usage?.code_usage_subscribed === true - ? (usage.ai_credits?.limit_usd ?? null) - : null; + const spendLimitUsd = codeOrgSpendLimitUsd(usage); useEffect(() => { if (isOpen) { @@ -83,10 +81,10 @@ export function UsageBillingAnnouncementModal() { • Premium models need a payment method; an open model stays free. - {limitUsd != null ? ( + {spendLimitUsd != null ? ( <> • Your organization's spend limit is{" "} - {`${formatUsdAmount(limitUsd)}/month`}{" "} + {`${formatUsdAmount(spendLimitUsd)}/month`}{" "} — adjust it any time in billing settings. ) : ( diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 8f470b5b8b..f6fce7457e 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -1,6 +1,7 @@ import { Circle } from "@phosphor-icons/react"; import { formatResetTime, + formatUsageBreakdown, formatUsdAmount, } from "@posthog/core/billing/usageDisplay"; import { @@ -70,6 +71,10 @@ export function UsageButton() { const resetLabel = formatResetTime( meter.kind === "dollars" ? meter.resetAt : meter.bucket.reset_at, ); + const breakdownLabel = + meter.kind === "dollars" && meter.breakdown + ? formatUsageBreakdown(meter.breakdown) + : null; // Upgrade-prompt analytics only apply to free-tier orgs — a subscribed // org's meter is not an upgrade prompt. @@ -158,7 +163,7 @@ export function UsageButton() { variant={blocked ? "destructive" : "default"} />
- {resetLabel} + {breakdownLabel ? `${breakdownLabel} · ${resetLabel}` : resetLabel}
diff --git a/packages/ui/src/features/billing/UsageMeter.stories.tsx b/packages/ui/src/features/billing/UsageMeter.stories.tsx new file mode 100644 index 0000000000..290d0fb41d --- /dev/null +++ b/packages/ui/src/features/billing/UsageMeter.stories.tsx @@ -0,0 +1,77 @@ +import { UsageMeter } from "@posthog/ui/features/billing/UsageMeter"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta: Meta = { + title: "Billing/UsageMeter", + component: UsageMeter, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// A subscribed org on default settings: the $70 limit renders as two +// segments — the $20 included allowance (green) and the $50 default spend +// limit (accent) — with a dot legend naming each amount. Usage still inside +// the included allowance only fills the green segment. +export const SubscribedWithBreakdown: Story = { + args: { + label: "Usage this period", + percent: 18, + valueLabel: "$12.40 of $70", + detail: "Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 12.4 }, + }, +}; + +// Past the allowance: the green segment is full and billable usage fills the +// accent segment. +export const SubscribedPastIncluded: Story = { + args: { + label: "Usage this period", + percent: 66, + valueLabel: "$46.20 of $70", + detail: "Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 46.2 }, + }, +}; + +// A subscribed org that set its spend limit to $0: only the included segment +// (and its legend entry) renders. +export const ZeroSpendLimit: Story = { + args: { + label: "Usage this period", + percent: 65, + valueLabel: "$13 of $20", + detail: "Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 0, usedUsd: 13 }, + }, +}; + +// Free tier has no breakdown — its limit IS the allowance, so the plain +// single-track bar renders. +export const FreeTier: Story = { + args: { + label: "Monthly free usage", + percent: 62, + valueLabel: "$12.40 of $20 included", + detail: "Resets Jul 31 at 2:00 PM PDT", + }, +}; + +export const Exceeded: Story = { + args: { + label: "Usage this period", + percent: 100, + valueLabel: "$70 of $70", + detail: "Limit exceeded. Resets Jul 31 at 2:00 PM PDT", + breakdown: { includedUsd: 20, spendLimitUsd: 50, usedUsd: 70 }, + color: "red", + }, +}; diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx index 9ea1c91a11..5b708bad12 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -1,19 +1,32 @@ +import { formatUsdAmount } from "@posthog/core/billing/usageDisplay"; +import { cn } from "@posthog/quill"; import { Flex, Progress, Text } from "@radix-ui/themes"; +interface UsageMeterBreakdown { + includedUsd: number; + spendLimitUsd: number; + usedUsd: number; +} + interface UsageMeterProps { label: string; percent: number; valueLabel: string; detail: string; color?: "red"; + breakdown?: UsageMeterBreakdown; } +const clampPercent = (value: number): number => + Math.min(100, Math.max(0, value)); + export function UsageMeter({ label, percent, valueLabel, detail, color, + breakdown, }: UsageMeterProps) { const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; @@ -31,12 +44,94 @@ export function UsageMeter({ {label} {valueLabel} - + {breakdown ? ( + + ) : ( + + )} {detail} ); } + +function SegmentedUsageBar({ + percent, + breakdown, + exceeded, +}: { + percent: number; + breakdown: UsageMeterBreakdown; + exceeded: boolean; +}) { + const { includedUsd, spendLimitUsd, usedUsd } = breakdown; + const totalUsd = includedUsd + spendLimitUsd; + const hasPaidSegment = spendLimitUsd > 0 && totalUsd > 0; + const includedFill = + includedUsd > 0 ? clampPercent((usedUsd / includedUsd) * 100) : 100; + const paidFill = hasPaidSegment + ? clampPercent(((usedUsd - includedUsd) / spendLimitUsd) * 100) + : 0; + const includedDot = exceeded ? "bg-(--red-9)" : "bg-(--green-9)"; + const paidDot = exceeded ? "bg-(--red-9)" : "bg-(--accent-9)"; + + return ( + +
+
+
+
+ {hasPaidSegment && ( +
+
+
+ )} +
+ + + + + {formatUsdAmount(includedUsd)} included + + + {hasPaidSegment && ( + + + + {formatUsdAmount(spendLimitUsd)} org spend limit + + + )} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 4261f1959a..17fe071e67 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -104,7 +104,7 @@ function ActivityRow({ diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 90fff8e7df..af127b60ab 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -4,6 +4,7 @@ import { GitBranchIcon, RobotIcon, } from "@phosphor-icons/react"; +import { taskFeedRunStatus } from "@posthog/core/canvas/channelFeed"; import { Avatar, AvatarFallback, @@ -37,13 +38,13 @@ import { } from "@posthog/quill"; import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; import type { Task, TaskRunStatus } from "@posthog/shared/domain-types"; -import { isTerminalStatus } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { TaskTabIcon } from "@posthog/ui/features/browser-tabs/TaskTabIcon"; import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; import { useTaskThread } from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { shouldOpenTaskCardInline } from "@posthog/ui/features/canvas/taskCardNavigation"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { type SidebarPrState, @@ -51,8 +52,10 @@ import { } from "@posthog/ui/features/sidebar/useTaskPrStatus"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; import { Fragment, + type MouseEvent, memo, type ReactNode, useEffect, @@ -166,6 +169,7 @@ function useTaskStatusDisplay(task: Task): TaskStatusDisplay { }); const status = data?.taskRunStatus ?? task.latest_run?.status; const environment = data?.taskRunEnvironment ?? task.latest_run?.environment; + const displayStatus = taskFeedRunStatus({ status, environment }); // `prState` is resolved async from git/`gh` and is routinely null for cloud // tasks (the details fetch hasn't landed, or there's no cached row). But the // PR URL itself is a hard signal a PR exists — the card's "PR" link keys off @@ -197,8 +201,8 @@ function useTaskStatusDisplay(task: Task): TaskStatusDisplay { base = null; } else if (!status) { base = Draft; - } else if (environment === "cloud" || isTerminalStatus(status)) { - base = statusBadge(status); + } else if (displayStatus) { + base = statusBadge(displayStatus); } else { // Local, non-terminal: the run status is unreliable (the backend row stays // "queued" while the agent runs on the creator's machine), so we render no @@ -258,61 +262,84 @@ const NO_PENDING: PendingKickoff[] = []; // The task the message kicked off, as a card everyone in the channel sees: // bold title + status up top, then run metadata. -function TaskCard({ task, onOpen }: { task: Task; onOpen: () => void }) { +export function TaskCard({ + task, + channelId, + onOpen, + inThread = false, +}: { + task: Task; + channelId: string; + onOpen?: () => void; + inThread?: boolean; +}) { const statusDisplay = useTaskStatusDisplay(task); const prUrl = typeof task.latest_run?.output?.pr_url === "string" ? task.latest_run.output.pr_url : undefined; const stage = task.latest_run?.stage; + const handleClick = (event: MouseEvent) => { + if (!onOpen || !shouldOpenTaskCardInline(event)) return; + event.preventDefault(); + onOpen(); + }; return ( - - -
-
- {/* Same live status icon as the code side nav, so the card and the - nav never disagree (generating spinner, needs-permission, cloud - status colors, PR state). */} - - - {task.title || "Untitled task"} - -
- -
- {(stage || task.repository || prUrl) && ( -
- {task.repository && ( - - - {task.repository} - - )} - {stage && ( - - {stage} - - )} - {prUrl && ( - - - PR + + +
+
+ + + {task.title || "Untitled task"} - )} +
+
- )} -
-
+ {(stage || task.repository || prUrl) && ( +
+ {task.repository && ( + + + {task.repository} + + )} + {stage && ( + + {stage} + + )} + {prUrl && ( + + + PR + + )} +
+ )} + + + ); } @@ -359,9 +386,7 @@ function ReplyFooter({ - - Reply - + Reply ); } @@ -388,11 +413,13 @@ function ReplyFooter({ const FeedItem = memo(function FeedItem({ task, + channelId, inView, onOpenTask, onOpenThread, }: { task: Task; + channelId: string; inView: boolean; onOpenTask: (task: Task) => void; onOpenThread: (task: Task) => void; @@ -435,7 +462,11 @@ const FeedItem = memo(function FeedItem({ )} - onOpenTask(task)} /> + onOpenThread(task)} + /> void; onOpenThread: (task: Task) => void; }) { @@ -483,6 +516,7 @@ function FeedRow({ > diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index aaee6d89aa..df0826b994 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -1,18 +1,20 @@ +import { RobotIcon } from "@phosphor-icons/react"; import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill"; import type { UserBasic } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; import { + type ComposerMentionCandidate, contentToDoc, docToContent, - filterMentionCandidates, + filterComposerMentionCandidates, } from "@posthog/ui/features/canvas/utils/mentionComposer"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -import { Text } from "@radix-ui/themes"; import Mention, { type MentionNodeAttrs } from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type ReactNode, useEffect, useRef, useState } from "react"; +import "./mention-chip.css"; import "./mention-composer.css"; interface MentionComposerProps { @@ -22,6 +24,7 @@ interface MentionComposerProps { onSubmit: () => void; /** The taggable pool; typically the org's members. */ members: UserBasic[]; + allowAgentMention?: boolean; onMentionInsert?: (member: UserBasic) => void; placeholder?: string; rows?: number; @@ -31,7 +34,7 @@ interface MentionComposerProps { } /** Styled like the chips MentionText renders on sent messages. */ -const MENTION_CHIP_CLASS = "rounded px-0.5 font-medium text-[var(--accent-11)]"; +const MENTION_CHIP_CLASS = "mention-chip"; // Padding lives on the editable element (not the input-group control) so a // click anywhere in the box focuses the editor. @@ -39,8 +42,8 @@ const EDITOR_CLASS = "w-full px-2.5 py-2 outline-none break-words [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]"; interface SuggestionSession { - items: UserBasic[]; - command: (member: UserBasic) => void; + items: ComposerMentionCandidate[]; + command: (candidate: ComposerMentionCandidate) => void; } /** @@ -54,6 +57,7 @@ export function MentionComposer({ onValueChange, onSubmit, members, + allowAgentMention = false, onMentionInsert, placeholder, rows, @@ -77,6 +81,8 @@ export function MentionComposer({ // latest props and popup state. const membersRef = useRef(members); membersRef.current = members; + const allowAgentMentionRef = useRef(allowAgentMention); + allowAgentMentionRef.current = allowAgentMention; const onValueChangeRef = useRef(onValueChange); onValueChangeRef.current = onValueChange; const onSubmitRef = useRef(onSubmit); @@ -120,11 +126,21 @@ export function MentionComposer({ char: "@", allowSpaces: true, items: ({ query }) => - filterMentionCandidates(membersRef.current, query), - // The suggestion pipeline carries whole members, not node attrs, - // so member data survives into `command`. + filterComposerMentionCandidates( + membersRef.current, + query, + allowAgentMentionRef.current, + ), command: ({ editor: e, range, props }) => { - const member = props as unknown as UserBasic; + const candidate = props as unknown as ComposerMentionCandidate; + if (candidate.kind === "agent") { + e.chain() + .focus() + .insertContentAt(range, { type: "text", text: "@agent " }) + .run(); + return; + } + const { member } = candidate; e.chain() .focus() .insertContentAt(range, [ @@ -142,17 +158,17 @@ export function MentionComposer({ setDismissed(false); setSelectedIndex(0); setSession({ - items: props.items as unknown as UserBasic[], - command: (member) => - props.command(member as unknown as MentionNodeAttrs), + items: props.items as unknown as ComposerMentionCandidate[], + command: (candidate) => + props.command(candidate as unknown as MentionNodeAttrs), }); }, onUpdate: (props) => { setSelectedIndex(0); setSession({ - items: props.items as unknown as UserBasic[], - command: (member) => - props.command(member as unknown as MentionNodeAttrs), + items: props.items as unknown as ComposerMentionCandidate[], + command: (candidate) => + props.command(candidate as unknown as MentionNodeAttrs), }); }, onKeyDown: ({ event }) => { @@ -171,8 +187,8 @@ export function MentionComposer({ return true; } if (event.key === "Enter" || event.key === "Tab") { - const member = items[highlightedRef.current]; - if (member) sessionRef.current?.command(member); + const candidate = items[highlightedRef.current]; + if (candidate) sessionRef.current?.command(candidate); return true; } return false; @@ -226,38 +242,47 @@ export function MentionComposer({
- {session.items.map((member, index) => ( + {session.items.map((candidate, index) => ( ))}
diff --git a/packages/ui/src/features/canvas/components/MentionText.test.tsx b/packages/ui/src/features/canvas/components/MentionText.test.tsx new file mode 100644 index 0000000000..f9d8a7d396 --- /dev/null +++ b/packages/ui/src/features/canvas/components/MentionText.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MentionText } from "./MentionText"; + +describe("MentionText", () => { + it("uses the shared mention styles and emphasizes the current user", () => { + render( + , + ); + + expect(screen.getByText("@Alice")).toHaveClass("mention-chip"); + expect(screen.getByText("@Alice")).not.toHaveClass("mention-chip--self"); + expect(screen.getByText("@Bob")).toHaveClass( + "mention-chip", + "mention-chip--self", + ); + }); + + it("highlights a literal agent mention", () => { + render(); + + expect(screen.getByText("@agent")).toHaveClass("mention-chip"); + }); + + it("does not highlight agent text inside another token", () => { + render(); + + expect(screen.queryByText("@agent")).not.toBeInTheDocument(); + }); + + it("inherits the surrounding message text size", () => { + render(); + + expect(screen.getByText("A thread reply")).not.toHaveClass("text-xs"); + }); +}); diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index ce6c4bd26a..39285e57d5 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -1,18 +1,18 @@ import { splitMentionSegments } from "@posthog/shared"; import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; -import { Text } from "@radix-ui/themes"; import { Fragment, useMemo } from "react"; +import "./mention-chip.css"; type RenderSegment = | { type: "text"; text: string } | { type: "link"; text: string; href: string } + | { type: "agent"; text: string } | { type: "mention"; name: string; email: string }; // The plain (not-the-viewer) mention chip look, also used by surfaces that // render a mention-styled name without real mention semantics (e.g. the // channel feed's "started a new task" row). -export const mentionChipClass = - "rounded px-0.5 font-medium text-[var(--accent-11)]"; +export const mentionChipClass = "mention-chip"; /** * Thread message content with inline mention tokens rendered as highlighted @@ -36,6 +36,22 @@ export function MentionText({ entries.push({ segment, key: `${offset}` }); offset += length; }; + const pushText = (text: string) => { + let cursor = 0; + for (const match of text.matchAll(/(^|\s)(@agent)\b/gi)) { + const mentionStart = (match.index ?? 0) + match[1].length; + for (const part of splitLinkSegments( + text.slice(cursor, mentionStart), + )) { + push(part, part.text.length); + } + push({ type: "agent", text: match[2] }, match[2].length); + cursor = mentionStart + match[2].length; + } + for (const part of splitLinkSegments(text.slice(cursor))) { + push(part, part.text.length); + } + }; for (const segment of splitMentionSegments(content)) { if (segment.type === "mention") { push( @@ -43,24 +59,29 @@ export function MentionText({ segment.text.length, ); } else { - for (const part of splitLinkSegments(segment.text)) { - push(part, part.text.length); - } + pushText(segment.text); } } return entries; }, [content]); const selfEmail = currentUserEmail?.toLowerCase(); return ( - + {segments.map(({ segment, key }) => { + if (segment.type === "agent") { + return ( + + {segment.text} + + ); + } if (segment.type === "mention") { return ( {segment.text}; })} - + ); } diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx new file mode 100644 index 0000000000..18bb9017db --- /dev/null +++ b/packages/ui/src/features/canvas/components/ThreadPanel.test.tsx @@ -0,0 +1,77 @@ +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { AgentStatusLine, UserPromptRow } from "./ThreadPanel"; +import { agentTurns } from "./threadAgentTurns"; + +describe("agentTurns", () => { + it("accumulates every text chunk in one agent turn", () => { + const items = [ + { + type: "session_update", + id: "first", + timestamp: 10, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hello" }, + }, + }, + { + type: "session_update", + id: "second", + timestamp: 20, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: " there" }, + }, + }, + ] as ConversationItem[]; + + expect(agentTurns(items)).toEqual([ + { id: "first", text: "Hello there", timestamp: 10 }, + ]); + }); +}); + +describe("AgentStatusLine", () => { + it("renders working status outside the conversation timeline", () => { + render(); + + const status = screen.getByText("Working…"); + + expect(status.closest("article")).toBeNull(); + expect(status.closest('[data-slot="thread-item-body"]')).toBeNull(); + expect(status.closest("output")).not.toBeNull(); + }); +}); + +describe("UserPromptRow", () => { + it("prefixes direct task prompts with @agent", () => { + render( + , + ); + + expect(screen.getByText("@agent")).toBeInTheDocument(); + expect(screen.getByText("Investigate this")).toBeInTheDocument(); + }); + + it("hides forwarded thread attribution and duplicate agent mentions", () => { + render( + , + ); + + expect(screen.getAllByText("@agent")).toHaveLength(1); + expect(screen.getByText("which model are you?")).toBeInTheDocument(); + expect(screen.queryByText(/Thread comment from/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 47e5ef82ea..2fd35d705e 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -1,4 +1,5 @@ import { + ArrowSquareOutIcon, CaretRightIcon, DotsThreeIcon, PaperPlaneRightIcon, @@ -6,6 +7,16 @@ import { TrashIcon, XIcon, } from "@phosphor-icons/react"; +import { + buildThreadTimeline, + deriveThreadAgentStatus, + hasAgentMention, + normalizeAgentPromptText, + shouldSuspendThreadSession, + type ThreadAgentMessage, + type ThreadAgentStatus, + type ThreadTimelineRow, +} from "@posthog/core/canvas/threadTimeline"; import { Avatar, AvatarFallback, @@ -15,11 +26,24 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, InputGroupAddon, InputGroupButton, Spinner, + ThreadItem, + ThreadItemAction, + ThreadItemActions, + ThreadItemAuthor, + ThreadItemBody, + ThreadItemContent, + ThreadItemGroup, + ThreadItemGutter, + ThreadItemHeader, } from "@posthog/quill"; -import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task, @@ -30,20 +54,38 @@ import { isTerminalStatus } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; +import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; -import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { + MentionText, + mentionChipClass, +} from "@posthog/ui/features/canvas/components/MentionText"; +import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; +import { agentTurns } from "@posthog/ui/features/canvas/components/threadAgentTurns"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { + useDeleteTaskThreadMessage, + usePostTaskThreadMessage, + usePostTaskThreadMessageToAgent, + useSendTaskThreadMessageToAgent, useTaskThread, - useTaskThreadMutations, } from "@posthog/ui/features/canvas/hooks/useTaskThread"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { + ChatMarkdown, + ChatStreamingMarkdown, +} from "@posthog/ui/features/sessions/components/chat-thread/ChatMarkdown"; +import { extractChannelContext } from "@posthog/ui/features/sessions/components/session-update/channelContext"; +import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; +import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; +import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; +import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; -import { Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; function ThreadMessageRow({ message, @@ -55,7 +97,6 @@ function ThreadMessageRow({ onDelete, }: { message: TaskThreadMessage; - /** Whether the current user authored the task (may forward to the agent). */ isTaskAuthor: boolean; isOwnMessage: boolean; currentUserEmail?: string | null; @@ -67,105 +108,451 @@ function ThreadMessageRow({ const showMenu = (isTaskAuthor && !forwarded) || isOwnMessage; return ( -
- - {getUserInitials(message.author)} - -
-
- - {userDisplayName(message.author)} - - - {formatRelativeTimeShort(message.created_at)} - -
- + + + + {getUserInitials(message.author)} + + + + + {userDisplayName(message.author)} + + + + + {forwarded && ( - + Sent to agent )} -
+ {showMenu && ( - - - - + + + + + + } + /> + + {isTaskAuthor && !forwarded && ( + + + Send to agent + + )} + {isOwnMessage && ( + + + Delete message + + )} + + + + )} + + ); +} + +function agentPrompts(items: ConversationItem[]): ThreadAgentMessage[] { + const prompts: ThreadAgentMessage[] = []; + for (const item of items) { + if (item.type !== "user_message") continue; + const text = ( + extractChannelContext(item.content)?.stripped ?? item.content + ).trim(); + if (!text) continue; + prompts.push({ id: item.id, text, timestamp: item.timestamp }); + } + return prompts; +} + +export function AgentStatusLine({ status }: { status: ThreadAgentStatus }) { + return ( + + {status.phase === "active" ? ( + + ) : ( + + )} + {status.label} + + ); +} + +export function AgentTurnRow({ + message, + streaming, +}: { + message: ThreadAgentMessage; + streaming: boolean; +}) { + return ( + + + + + + + + + + + Agent + {message.timestamp !== undefined && ( + + )} + + {message.text && ( + +
+ {streaming ? ( + + ) : ( + + )} +
+
+ )} +
+
+ ); +} + +export function UserPromptRow({ + message, + author, +}: { + message: ThreadAgentMessage; + author: TaskThreadMessage["author"]; +}) { + const promptText = normalizeAgentPromptText(message.text); + + return ( + + + + {getUserInitials(author)} + + + + + {userDisplayName(author)} + {message.timestamp !== undefined && ( + + )} + + + @agent {promptText} + + + + ); +} + +function ThreadLoadingState() { + return ( + + + + + + Loading thread + + + ); +} + +function ThreadHeader({ + onClose, + onToggleCollapsed, + onOpenFull, +}: { + onClose?: () => void; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; +}) { + return ( +
+
+ Thread +
+ {onOpenFull && ( + + )} + {onToggleCollapsed && ( + + )} + {onClose && ( + + )} +
+ ); +} + +function ThreadTimeline({ + timeline, + isReady, + taskAuthor, + currentUserUuid, + currentUserEmail, + isTaskAuthor, + canForward, + lastAgentId, + agentActive, + onSendToAgent, + onDelete, +}: { + timeline: ThreadTimelineRow[]; + isReady: boolean; + taskAuthor: UserBasic | null | undefined; + currentUserUuid?: string; + currentUserEmail?: string; + isTaskAuthor: boolean; + canForward: boolean; + lastAgentId?: string; + agentActive: boolean; + onSendToAgent: (messageId: string) => void; + onDelete: (messageId: string) => void; +}) { + if (!isReady) return ; + if (timeline.length === 0) { + return ( + + + + + + No messages yet + + Discuss this task with your team. The agent's status shows up here + too; messages stay between humans unless the task author sends one + to the agent. + + + + ); + } + + return ( + + {timeline.map((row) => + row.kind === "prompt" ? ( + + ) : row.kind === "human" ? ( + onSendToAgent(row.message.id)} + onDelete={() => onDelete(row.message.id)} /> - - {isTaskAuthor && !forwarded && ( - - - Send to agent - - )} - {isOwnMessage && ( - - - Delete message - - )} - -
+ ) : ( + + ), )} + + ); +} + +function ThreadReplyComposer({ + draft, + onDraftChange, + onSubmit, + members, + allowAgentMention, + onMentionInsert, + disabled, +}: { + draft: string; + onDraftChange: (value: string) => void; + onSubmit: () => void; + members: UserBasic[]; + allowAgentMention: boolean; + onMentionInsert: (member: UserBasic) => void; + disabled: boolean; +}) { + return ( +
+ + + + + + + + +
); } -// The right-hand thread dock: the human-only conversation around a task. -// Nothing here reaches the agent unless the task author explicitly forwards a -// message ("Send to agent" in the row's hover menu). -export function ThreadPanel({ - taskId, - task: taskProp, +function ThreadConversation({ + task, + channelId, onClose, - collapsed, onToggleCollapsed, - showTaskTitle = true, + onOpenFull, + showTaskSummary, }: { - taskId: string; - /** The thread's task when the caller already has it; fetched otherwise. */ - task?: Task; + task: Task; + channelId: string; onClose?: () => void; - collapsed?: boolean; onToggleCollapsed?: () => void; - /** - * Show the task title under the "Thread" heading. Hidden in the task detail - * view, where the TaskDetail header already names the task. - */ - showTaskTitle?: boolean; + onOpenFull?: () => void; + showTaskSummary: boolean; }) { + const taskId = task.id; const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); - const { data: fetchedTask } = useQuery({ - ...taskDetailQuery(taskId), - enabled: !taskProp, - }); - const task = taskProp ?? fetchedTask; - const { messages, isLoading } = useTaskThread(collapsed ? undefined : taskId); + const { messages, isLoading } = useTaskThread(taskId); + const { postMessage, isPosting } = usePostTaskThreadMessage(taskId); + const { postMessageToAgent, isPostingToAgent } = + usePostTaskThreadMessageToAgent(taskId); + const { deleteMessage } = useDeleteTaskThreadMessage(taskId); + const { sendToAgent, isSending } = useSendTaskThreadMessageToAgent(taskId); + const isSendingToAgent = isPostingToAgent || isSending; + const { members } = useOrgMembers(); + const { - postMessage, - deleteMessage, - sendToAgent, - isPosting, - isSendingToAgent, - } = useTaskThreadMutations(taskId); - const { members } = useOrgMembers({ enabled: !collapsed }); + session, + repoPath, + isCloud, + events, + cloudStatus, + isPromptPending, + isInitializing, + hasError, + errorTitle, + } = useSessionViewState(taskId, task); + useSessionConnection({ + taskId, + task, + session, + repoPath, + isCloud, + isSuspended: shouldSuspendThreadSession({ + isCloud, + hasRun: Boolean(task.latest_run?.id), + hasSession: Boolean(session), + }), + }); + const { items } = useConversationItems(events, isPromptPending); + const pendingPermissions = usePendingPermissionsForTask(taskId); + const agentMsgs = useMemo(() => agentTurns(items), [items]); + const promptMsgs = useMemo(() => agentPrompts(items), [items]); + + const agentStatus = useMemo( + () => + deriveThreadAgentStatus({ + hasActivity: events.length > 0 || !!task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissionCount: pendingPermissions.size, + isPromptPending, + isInitializing, + }), + [ + events.length, + task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissions.size, + isPromptPending, + isInitializing, + ], + ); + + const timeline = useMemo( + () => + buildThreadTimeline({ + prompts: promptMsgs, + agentMessages: agentMsgs, + humanMessages: messages.map((message) => ({ + id: message.id, + content: message.content, + createdAt: message.created_at, + forwardedToAgent: !!message.forwarded_to_agent_at, + value: message, + })), + }), + [promptMsgs, messages, agentMsgs], + ); + + const lastAgentId = agentMsgs[agentMsgs.length - 1]?.id; const [draft, setDraft] = useState(""); const scrollRef = useRef(null); @@ -182,30 +569,50 @@ export function ThreadPanel({ [taskId], ); - // Keep the newest message in view, Slack-style. - // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [messages.length]); + }, [timeline, agentStatus?.phase]); const isTaskAuthor = - !!currentUser?.uuid && currentUser.uuid === task?.created_by?.uuid; - // Forwarding needs a run the workflow can still signal, one send at a time. + !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; const canForward = - !!task?.latest_run && + !!task.latest_run && !isTerminalStatus(task.latest_run.status) && !isSendingToAgent; - const submit = () => { + const submit = async () => { const content = draft.trim(); - if (!content || isPosting) return; + if (!content || isPosting || isSendingToAgent) return; + const sendToAgentRequested = hasAgentMention(content); + if (sendToAgentRequested && (!isTaskAuthor || !canForward)) { + toast.error("Couldn't send to agent", { + description: + "Only the task author can @agent while the task has an active run.", + }); + return; + } setDraft(""); - postMessage(content).catch((error: unknown) => { + try { + if (sendToAgentRequested) { + const { sendError } = await postMessageToAgent(content); + if (sendError) { + toast.error("Message posted, but couldn't send it to the agent", { + description: + sendError instanceof Error + ? sendError.message + : String(sendError), + }); + } + } else { + await postMessage(content); + } + } catch (error) { setDraft(content); toast.error("Couldn't post message", { description: error instanceof Error ? error.message : String(error), }); - }); + } }; const handleSendToAgent = (messageId: string) => { @@ -224,6 +631,77 @@ export function ThreadPanel({ }); }; + const isReady = !isInitializing && !isLoading; + + return ( +
+ + + {showTaskSummary && ( +
+ +
+ )} +
+ +
+ + {agentStatus && } + + +
+ ); +} + +export function ThreadPanel({ + taskId, + channelId, + task: taskProp, + onClose, + collapsed, + onToggleCollapsed, + onOpenFull, + showTaskSummary = true, +}: { + taskId: string; + channelId: string; + task?: Task; + onClose?: () => void; + collapsed?: boolean; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; + showTaskSummary?: boolean; +}) { + const { data: fetchedTask } = useQuery({ + ...taskDetailQuery(taskId), + enabled: !taskProp && !collapsed, + }); + const task = taskProp ?? fetchedTask; + if (collapsed) { return (
@@ -239,100 +717,18 @@ export function ThreadPanel({ ); } - return ( -
-
-
- - Thread - - {showTaskTitle && task && ( - - {task.title || "Untitled task"} - - )} -
- {onToggleCollapsed && ( - - )} - {onClose && ( - - )} -
- -
- {isLoading && messages.length === 0 ? ( -
- -
- ) : messages.length === 0 ? ( -
- - Discuss this task with your team. Messages stay between humans - unless the task author sends one to the agent. - -
- ) : ( -
- {messages.map((message) => ( - handleSendToAgent(message.id)} - onDelete={() => handleDelete(message.id)} - /> - ))} -
- )} -
+ if (!task) { + return ; + } -
- - - - - - - - - -
-
+ return ( + ); } diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index fb4d7703dd..4a81cf8700 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -12,16 +12,19 @@ import { useState } from "react"; // re-render on every resize tick. export function ThreadSidebar({ taskId, + channelId, task, onClose, - showTaskTitle, + onOpenFull, + showTaskSummary, }: { taskId: string; + channelId: string; /** The thread's task when the caller already has it; fetched otherwise. */ task?: Task; onClose?: () => void; - /** Forwarded to ThreadPanel; hidden in the task detail view. */ - showTaskTitle?: boolean; + onOpenFull?: () => void; + showTaskSummary?: boolean; }) { const collapsed = useThreadPanelStore((s) => s.collapsed); const width = useThreadPanelStore((s) => s.width); @@ -42,6 +45,7 @@ export function ThreadSidebar({ return ( toggleCollapsed(false)} @@ -60,10 +64,12 @@ export function ThreadSidebar({ > toggleCollapsed(true)} - showTaskTitle={showTaskTitle} + onOpenFull={onOpenFull} + showTaskSummary={showTaskSummary} /> ); diff --git a/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx new file mode 100644 index 0000000000..ef754f19e8 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ThreadTimestamp.tsx @@ -0,0 +1,45 @@ +import { + ThreadItemTimestamp, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; + +function ordinal(value: number): string { + const remainder = value % 100; + const suffixes = ["th", "st", "nd", "rd"]; + return `${value}${suffixes[(remainder - 20) % 10] ?? suffixes[remainder] ?? suffixes[0]}`; +} + +function formatClock(date: Date): string { + const minutes = String(date.getMinutes()).padStart(2, "0"); + const meridiem = date.getHours() >= 12 ? "pm" : "am"; + const hour = date.getHours() % 12 || 12; + return `${hour}:${minutes}${meridiem}`; +} + +function formatTooltip(date: Date): string { + const month = date.toLocaleString("en-US", { month: "long" }); + return `${month} ${ordinal(date.getDate())} at ${formatClock(date)}`; +} + +export function ThreadTimestamp({ dateTime }: { dateTime: string }) { + const date = new Date(dateTime); + if (Number.isNaN(date.getTime())) return null; + + return ( + + + + {formatClock(date)} + + } + /> + {formatTooltip(date)} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 25e00f8bc4..0159ab0aa0 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -43,7 +43,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { Heading, Text } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; // A channel: a Slack-style multiplayer feed. Each member message kicks off a // task rendered as a card everyone in the channel sees; the composer stays @@ -116,16 +116,12 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { // onboarding checklist. Describe-mode: seeds a plan session for this context. const [contextMdDialogOpen, setContextMdDialogOpen] = useState(false); - const threadTaskId = useThreadPanelStore((s) => s.taskId); + const threadTaskId = useThreadPanelStore( + (s) => s.openByChannel[channelId] ?? null, + ); const openThread = useThreadPanelStore((s) => s.openThread); const closeThread = useThreadPanelStore((s) => s.closeThread); - // A thread from another channel shouldn't linger when switching feeds. - // biome-ignore lint/correctness/useExhaustiveDependencies: re-close per channel - useEffect(() => { - closeThread(); - }, [closeThread, channelId]); - const handleSuggestionSelect = useCallback( (prompt: string, mode?: string) => { composerRef.current?.applySuggestion(prompt, mode); @@ -180,21 +176,23 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { [backendChannel?.id, channelId, fileTask, invalidateFeed, queryClient], ); - // The task route's mount effect points the panel at the task, so navigating - // is enough here. - const handleOpenTask = useCallback( - (task: Task) => { + const handleOpenFull = useCallback( + (taskId: string) => { void navigate({ to: "/website/$channelId/tasks/$taskId", - params: { channelId, taskId: task.id }, + params: { channelId, taskId }, }); }, [channelId, navigate], ); + const handleOpenTask = useCallback( + (task: Task) => handleOpenFull(task.id), + [handleOpenFull], + ); const handleOpenThread = useCallback( - (task: Task) => openThread(task.id), - [openThread], + (task: Task) => openThread(channelId, task.id), + [channelId, openThread], ); const threadTask = threadTaskId @@ -271,6 +269,7 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
closeThread(channelId)} + onOpenFull={() => handleOpenFull(threadTaskId)} /> )} diff --git a/packages/ui/src/features/canvas/components/mention-chip.css b/packages/ui/src/features/canvas/components/mention-chip.css new file mode 100644 index 0000000000..55a96cab4b --- /dev/null +++ b/packages/ui/src/features/canvas/components/mention-chip.css @@ -0,0 +1,12 @@ +.mention-chip { + border-radius: var(--radius-1); + background: color-mix(in oklab, var(--primary) 10%, transparent); + color: color-mix(in oklab, var(--primary) 80%, transparent); + font-weight: 500; + padding-inline: 0.125rem; +} + +.mention-chip--self { + background: color-mix(in oklab, var(--primary) 50%, transparent); + color: var(--primary); +} diff --git a/packages/ui/src/features/canvas/components/threadAgentTurns.ts b/packages/ui/src/features/canvas/components/threadAgentTurns.ts new file mode 100644 index 0000000000..ddcc58ec80 --- /dev/null +++ b/packages/ui/src/features/canvas/components/threadAgentTurns.ts @@ -0,0 +1,33 @@ +import type { ThreadAgentMessage } from "@posthog/core/canvas/threadTimeline"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; + +export function agentTurns(items: ConversationItem[]): ThreadAgentMessage[] { + const turns: ThreadAgentMessage[] = []; + let current: ThreadAgentMessage | null = null; + for (const item of items) { + if (item.type === "user_message") { + if (current) turns.push(current); + current = null; + continue; + } + if ( + item.type === "session_update" && + item.update.sessionUpdate === "agent_message_chunk" && + "content" in item.update && + item.update.content.type === "text" && + item.update.content.text.trim() + ) { + if (current) { + current.text += item.update.content.text; + } else { + current = { + id: item.id, + text: item.update.content.text, + timestamp: item.timestamp, + }; + } + } + } + if (current) turns.push(current); + return turns; +} diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts new file mode 100644 index 0000000000..e3cad7c73b --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.test.ts @@ -0,0 +1,54 @@ +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useChannelFeedMessages } from "./useChannelFeedMessages"; + +vi.mock("@posthog/ui/hooks/useAuthenticatedQuery", () => ({ + useAuthenticatedQuery: vi.fn(() => ({ data: [], isLoading: false })), +})); + +describe("useChannelFeedMessages", () => { + beforeEach(() => { + vi.mocked(useAuthenticatedQuery).mockClear(); + }); + + function refetchInterval() { + renderHook(() => useChannelFeedMessages("channel-id")); + + return vi.mocked(useAuthenticatedQuery).mock.calls[0]?.[2]?.refetchInterval; + } + + it("keeps polling after a transient query error", () => { + const interval = refetchInterval(); + + expect(interval).toBeTypeOf("function"); + expect( + typeof interval === "function" + ? interval({ state: { error: new Error("Network failure") } } as never) + : interval, + ).toBe(5_000); + }); + + it.each([401, 403, 404])( + "stops polling after a permanent %s response", + (status) => { + const interval = refetchInterval(); + const error = Object.assign(new Error("Request failed"), { status }); + + expect(interval).toBeTypeOf("function"); + expect( + typeof interval === "function" + ? interval({ state: { error } } as never) + : interval, + ).toBe(false); + }, + ); + + it("disables query retries", () => { + renderHook(() => useChannelFeedMessages("channel-id")); + + expect(vi.mocked(useAuthenticatedQuery).mock.calls[0]?.[2]?.retry).toBe( + false, + ); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts index 4ffb818045..0a3f2d0e0f 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelFeedMessages.ts @@ -1,3 +1,4 @@ +import { shouldPollChannelFeed } from "@posthog/core/canvas/channelFeed"; import type { ChannelFeedMessage, TaskChannel, @@ -80,14 +81,11 @@ export function useChannelFeedMessages(channelId: string | undefined): { (client) => client.getChannelFeed(channelId as string), { enabled: !!channelId, - // The endpoint may not be deployed yet (posthog#70320): don't retry, and - // stop polling once the query errors — otherwise every open channel view - // streams 404s (poll tick × default retries) forever. retry: false, refetchInterval: (query) => - query.state.status === "error" - ? false - : CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS, + shouldPollChannelFeed(query.state.error) + ? CHANNEL_FEED_MESSAGES_POLL_INTERVAL_MS + : false, }, ); const messages = useMemo( diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx new file mode 100644 index 0000000000..7f19e98753 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.test.tsx @@ -0,0 +1,94 @@ +import type { TaskThreadMessage } from "@posthog/shared/domain-types"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockClient = vi.hoisted(() => ({ + createTaskThreadMessage: vi.fn(), + sendTaskThreadMessageToAgent: vi.fn(), +})); +const mockTaskThreadService = vi.hoisted(() => ({ + postMessageToAgent: vi.fn(), +})); + +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => mockClient, +})); +vi.mock("@posthog/di/react", () => ({ + useService: () => mockTaskThreadService, +})); + +import { usePostTaskThreadMessageToAgent } from "./useTaskThread"; + +let queryClient: QueryClient; + +function wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); +} + +function message(overrides?: Partial): TaskThreadMessage { + return { + id: "message-id", + task: "task-id", + content: "@agent investigate this", + created_at: "2026-07-16T00:00:00Z", + ...overrides, + }; +} + +describe("usePostTaskThreadMessageToAgent", () => { + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + }); + + it("posts an @agent message through the combined client operation", async () => { + mockTaskThreadService.postMessageToAgent.mockResolvedValue({ + message: message({ forwarded_to_agent_at: "2026-07-16T00:00:01Z" }), + sendError: null, + }); + const { result } = renderHook( + () => usePostTaskThreadMessageToAgent("task-id"), + { wrapper }, + ); + + await act(async () => { + await result.current.postMessageToAgent("@agent investigate this"); + }); + + expect(mockTaskThreadService.postMessageToAgent).toHaveBeenCalledWith( + mockClient, + "task-id", + "@agent investigate this", + ); + }); + + it("returns a forwarding error after the message has been posted", async () => { + const sendError = new Error("No active run"); + mockTaskThreadService.postMessageToAgent.mockResolvedValue({ + message: message(), + sendError, + }); + const { result } = renderHook( + () => usePostTaskThreadMessageToAgent("task-id"), + { wrapper }, + ); + + let outcome: Awaited< + ReturnType + > | null = null; + await act(async () => { + outcome = await result.current.postMessageToAgent( + "@agent investigate this", + ); + }); + + expect(outcome).toEqual({ message: message(), sendError }); + expect(mockTaskThreadService.postMessageToAgent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/features/canvas/hooks/useTaskThread.ts b/packages/ui/src/features/canvas/hooks/useTaskThread.ts index 7f47f7e889..a5d09da53e 100644 --- a/packages/ui/src/features/canvas/hooks/useTaskThread.ts +++ b/packages/ui/src/features/canvas/hooks/useTaskThread.ts @@ -1,8 +1,12 @@ +import { + TASK_THREAD_SERVICE, + type TaskThreadService, +} from "@posthog/core/canvas/taskThreadService"; +import { useService } from "@posthog/di/react"; import type { TaskThreadMessage } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; const THREAD_POLL_INTERVAL_MS = 5_000; @@ -10,18 +14,9 @@ export function taskThreadQueryKey(taskId: string | undefined) { return ["task-thread", taskId ?? "none"] as const; } -/** A task's thread — the human side conversation — in chronological order. */ export function useTaskThread( taskId: string | undefined, - options?: { - /** Poll cadence override; feed rows poll slower than the open panel. */ - pollIntervalMs?: number; - /** - * Gate the fetch/poll. Feed rows pass `inView` so only near-viewport rows - * hit the network; off-screen rows keep any cached messages but idle. - */ - enabled?: boolean; - }, + options?: { pollIntervalMs?: number; enabled?: boolean }, ): { messages: TaskThreadMessage[]; isLoading: boolean; @@ -34,54 +29,68 @@ export function useTaskThread( { enabled: !!taskId && enabled, refetchInterval: pollIntervalMs, - // Fresh-within-the-poll-window so focus/remount doesn't refire every - // feed row's thread query on top of the interval. staleTime: pollIntervalMs, }, ); return { messages: query.data ?? [], isLoading: query.isLoading }; } -export function useTaskThreadMutations(taskId: string | undefined) { +export function usePostTaskThreadMessage(taskId: string | undefined) { const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); - - const invalidate = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: taskThreadQueryKey(taskId), - }); - }, [queryClient, taskId]); - - const postMutation = useMutation({ + const mutation = useMutation({ mutationFn: async (content: string) => { if (!client || !taskId) throw new Error("Not authenticated"); return client.createTaskThreadMessage(taskId, content); }, - onSuccess: invalidate, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), }); + return { postMessage: mutation.mutateAsync, isPosting: mutation.isPending }; +} - const deleteMutation = useMutation({ +export function usePostTaskThreadMessageToAgent(taskId: string | undefined) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const service = useService(TASK_THREAD_SERVICE); + const mutation = useMutation({ + mutationFn: async (content: string) => { + if (!client || !taskId) throw new Error("Not authenticated"); + return service.postMessageToAgent(client, taskId, content); + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), + }); + return { + postMessageToAgent: mutation.mutateAsync, + isPostingToAgent: mutation.isPending, + }; +} + +export function useDeleteTaskThreadMessage(taskId: string | undefined) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const mutation = useMutation({ mutationFn: async (messageId: string) => { if (!client || !taskId) throw new Error("Not authenticated"); return client.deleteTaskThreadMessage(taskId, messageId); }, - onSuccess: invalidate, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), }); + return { deleteMessage: mutation.mutateAsync }; +} - const sendToAgentMutation = useMutation({ +export function useSendTaskThreadMessageToAgent(taskId: string | undefined) { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); + const mutation = useMutation({ mutationFn: async (messageId: string) => { if (!client || !taskId) throw new Error("Not authenticated"); return client.sendTaskThreadMessageToAgent(taskId, messageId); }, - onSuccess: invalidate, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: taskThreadQueryKey(taskId) }), }); - - return { - postMessage: (content: string) => postMutation.mutateAsync(content), - deleteMessage: (messageId: string) => deleteMutation.mutateAsync(messageId), - sendToAgent: (messageId: string) => - sendToAgentMutation.mutateAsync(messageId), - isPosting: postMutation.isPending, - isSendingToAgent: sendToAgentMutation.isPending, - }; + return { sendToAgent: mutation.mutateAsync, isSending: mutation.isPending }; } diff --git a/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts b/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts new file mode 100644 index 0000000000..722bd29263 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/threadPanelStore.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useThreadPanelStore } from "./threadPanelStore"; + +describe("threadPanelStore", () => { + beforeEach(() => { + useThreadPanelStore.setState({ + openByChannel: {}, + collapsed: false, + width: 360, + }); + }); + + it("keeps each channel tab's open thread independent", () => { + const { openThread } = useThreadPanelStore.getState(); + + openThread("channel-a", "task-a"); + openThread("channel-b", "task-b"); + + expect(useThreadPanelStore.getState().openByChannel).toEqual({ + "channel-a": "task-a", + "channel-b": "task-b", + }); + }); + + it("closes only the active channel's thread", () => { + useThreadPanelStore.setState({ + openByChannel: { "channel-a": "task-a", "channel-b": "task-b" }, + }); + + useThreadPanelStore.getState().closeThread("channel-a"); + + expect(useThreadPanelStore.getState().openByChannel).toEqual({ + "channel-a": null, + "channel-b": "task-b", + }); + }); +}); diff --git a/packages/ui/src/features/canvas/stores/threadPanelStore.ts b/packages/ui/src/features/canvas/stores/threadPanelStore.ts index e003ad89cb..ee2c66427e 100644 --- a/packages/ui/src/features/canvas/stores/threadPanelStore.ts +++ b/packages/ui/src/features/canvas/stores/threadPanelStore.ts @@ -2,20 +2,18 @@ import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; -// View state for the thread side panel — the right-hand human conversation -// dock next to a channel feed or task detail. Which thread is open is -// per-navigation state; collapse and width are persisted user preferences so -// the panel keeps its shape across tasks and channels. const DEFAULT_PANEL_WIDTH = 360; interface ThreadPanelState { - /** Task whose thread is open, or null when the panel is closed. */ - taskId: string | null; + openByChannel: Record; collapsed: boolean; width: number; - /** Points the panel at a task; expands it unless `expand: false`. */ - openThread: (taskId: string, opts?: { expand?: boolean }) => void; - closeThread: () => void; + openThread: ( + channelId: string, + taskId: string, + opts?: { expand?: boolean }, + ) => void; + closeThread: (channelId: string) => void; setCollapsed: (collapsed: boolean) => void; setWidth: (width: number) => void; } @@ -23,12 +21,18 @@ interface ThreadPanelState { export const useThreadPanelStore = create()( persist( (set) => ({ - taskId: null, + openByChannel: {}, collapsed: false, width: DEFAULT_PANEL_WIDTH, - openThread: (taskId, opts) => - set(opts?.expand === false ? { taskId } : { taskId, collapsed: false }), - closeThread: () => set({ taskId: null }), + openThread: (channelId, taskId, opts) => + set((state) => ({ + openByChannel: { ...state.openByChannel, [channelId]: taskId }, + ...(opts?.expand === false ? {} : { collapsed: false }), + })), + closeThread: (channelId) => + set((state) => ({ + openByChannel: { ...state.openByChannel, [channelId]: null }, + })), setCollapsed: (collapsed) => set({ collapsed }), setWidth: (width) => set({ width }), }), diff --git a/packages/ui/src/features/canvas/taskCardNavigation.test.ts b/packages/ui/src/features/canvas/taskCardNavigation.test.ts new file mode 100644 index 0000000000..667ff66870 --- /dev/null +++ b/packages/ui/src/features/canvas/taskCardNavigation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { shouldOpenTaskCardInline } from "./taskCardNavigation"; + +const PRIMARY_CLICK = { + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, +}; + +describe("shouldOpenTaskCardInline", () => { + it("opens an unmodified primary click in the thread dock", () => { + expect(shouldOpenTaskCardInline(PRIMARY_CLICK)).toBe(true); + }); + + it.each([ + { defaultPrevented: true }, + { button: 1 }, + { metaKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + { altKey: true }, + ])("leaves browser navigation intact for %o", (override) => { + expect(shouldOpenTaskCardInline({ ...PRIMARY_CLICK, ...override })).toBe( + false, + ); + }); +}); diff --git a/packages/ui/src/features/canvas/taskCardNavigation.ts b/packages/ui/src/features/canvas/taskCardNavigation.ts new file mode 100644 index 0000000000..27ad8f0abb --- /dev/null +++ b/packages/ui/src/features/canvas/taskCardNavigation.ts @@ -0,0 +1,21 @@ +export interface TaskCardPointerIntent { + defaultPrevented: boolean; + button: number; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +} + +export function shouldOpenTaskCardInline( + event: TaskCardPointerIntent, +): boolean { + return ( + !event.defaultPrevented && + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ); +} diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts index e6a4f52658..640ee506a8 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { contentToDoc, docToContent, + filterComposerMentionCandidates, filterMentionCandidates, } from "./mentionComposer"; @@ -68,6 +69,22 @@ describe("filterMentionCandidates", () => { }); }); +describe("filterComposerMentionCandidates", () => { + it("offers the agent before matching teammates when enabled", () => { + expect(filterComposerMentionCandidates(members, "a", true)).toEqual([ + { kind: "agent" }, + { kind: "member", member: ann }, + { kind: "member", member: raquel }, + ]); + }); + + it("does not offer the agent when forwarding is unavailable", () => { + expect(filterComposerMentionCandidates(members, "agent", false)).toEqual( + [], + ); + }); +}); + describe("contentToDoc / docToContent", () => { const schema = getSchema([StarterKit, Mention]); diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.ts b/packages/ui/src/features/canvas/utils/mentionComposer.ts index 4a4d923fde..6f63a51924 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.ts @@ -4,6 +4,10 @@ import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import type { Node as PmNode } from "@tiptap/pm/model"; import type { JSONContent } from "@tiptap/react"; +export type ComposerMentionCandidate = + | { kind: "agent" } + | { kind: "member"; member: UserBasic }; + /** Members matching the query, best-first: name prefix, word prefix, email, substring. */ export function filterMentionCandidates( members: UserBasic[], @@ -32,6 +36,24 @@ export function filterMentionCandidates( .map((entry) => entry.member); } +export function filterComposerMentionCandidates( + members: UserBasic[], + query: string, + includeAgent: boolean, + limit = 8, +): ComposerMentionCandidate[] { + const normalizedQuery = query.trim().toLowerCase(); + const includeAgentCandidate = + includeAgent && (!normalizedQuery || "agent".startsWith(normalizedQuery)); + const memberLimit = Math.max(0, limit - (includeAgentCandidate ? 1 : 0)); + return [ + ...(includeAgentCandidate ? [{ kind: "agent" as const }] : []), + ...filterMentionCandidates(members, query, memberLimit).map( + (member): ComposerMentionCandidate => ({ kind: "member", member }), + ), + ]; +} + /** Serialize the composer's editor doc back to content with inline mention tokens. */ export function docToContent(doc: PmNode): string { const lines: string[] = []; diff --git a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx index 09d0db0884..e794e5afd1 100644 --- a/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx +++ b/packages/ui/src/features/settings/sections/PlanUsageSettings.tsx @@ -179,6 +179,11 @@ export function PlanUsageSettings() { percent={meter.percent} valueLabel={`${formatUsdAmount(meter.usedUsd)} of ${formatUsdAmount(meter.limitUsd)}${freeTier ? " included" : ""}`} detail={`${meter.exceeded ? "Limit exceeded. " : ""}${formatResetTime(meter.resetAt)}`} + breakdown={ + meter.breakdown + ? { ...meter.breakdown, usedUsd: meter.usedUsd } + : undefined + } color={meter.exceeded ? "red" : undefined} /> ) : meter.kind === "bucket" ? ( diff --git a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx index 85fbec93f8..b50fe78fe3 100644 --- a/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx +++ b/packages/ui/src/router/routes/website/$channelId/tasks/$taskId.tsx @@ -1,7 +1,6 @@ import type { Task } from "@posthog/shared/domain-types"; import { ThreadSidebar } from "@posthog/ui/features/canvas/components/ThreadSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; import { TaskDetail } from "@posthog/ui/features/task-detail/components/TaskDetail"; import { @@ -49,13 +48,6 @@ function ChannelTaskDetailRoute() { markAsViewed(taskId); }, [taskId, markAsViewed]); - // Opening a task shows its thread docked on the right, keeping the user's - // collapse preference. The panel follows the task being viewed. - const openThread = useThreadPanelStore((s) => s.openThread); - useEffect(() => { - openThread(taskId, { expand: false }); - }, [openThread, taskId]); - const { data: fetched } = useQuery({ ...taskDetailQuery(taskId), enabled: !fromList && !loaderTask, @@ -77,7 +69,12 @@ function ChannelTaskDetailRoute() { channelId={channelId} />
- +
); }