diff --git a/skills/pr-report/SKILL.md b/.agents/skills/pr-report/SKILL.md similarity index 100% rename from skills/pr-report/SKILL.md rename to .agents/skills/pr-report/SKILL.md diff --git a/skills/pr-report/assets/html-report-starter.html b/.agents/skills/pr-report/assets/html-report-starter.html similarity index 100% rename from skills/pr-report/assets/html-report-starter.html rename to .agents/skills/pr-report/assets/html-report-starter.html diff --git a/skills/pr-report/references/style-guide.md b/.agents/skills/pr-report/references/style-guide.md similarity index 100% rename from skills/pr-report/references/style-guide.md rename to .agents/skills/pr-report/references/style-guide.md diff --git a/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md similarity index 100% rename from skills/release-changelog/SKILL.md rename to .agents/skills/release-changelog/SKILL.md diff --git a/skills/release/SKILL.md b/.agents/skills/release/SKILL.md similarity index 99% rename from skills/release/SKILL.md rename to .agents/skills/release/SKILL.md index 5f39ba76..2eac6ad8 100644 --- a/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -33,7 +33,7 @@ Use this skill when leadership asks for: Before proceeding, verify all of the following: -1. `skills/release-changelog/SKILL.md` exists and is usable. +1. `.agents/skills/release-changelog/SKILL.md` exists and is usable. 2. The repo working tree is clean, including untracked files. 3. There are commits since the last stable tag. 4. The release SHA has passed the verification gate or is about to. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index b1adb579..1ca1409b 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -162,17 +162,73 @@ paperclipai worktree env eval "$(paperclipai worktree env)" ``` -Useful options: +### Worktree CLI Reference + +**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree. + +| Option | Description | +|---|---| +| `--name ` | Display name used to derive the instance id | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source PAPERCLIP_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | + +Examples: ```sh paperclipai worktree init --no-seed -paperclipai worktree init --seed-mode minimal paperclipai worktree init --seed-mode full paperclipai worktree init --from-instance default paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --force ``` +**`pnpm paperclipai worktree:make [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step. + +| Option | Description | +|---|---| +| `--start-point ` | Remote ref to base the new branch on (e.g. `origin/main`) | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source PAPERCLIP_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | + +Examples: + +```sh +pnpm paperclipai worktree:make paperclip-pr-432 +pnpm paperclipai worktree:make my-feature --start-point origin/main +pnpm paperclipai worktree:make experiment --no-seed +``` + +**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance. + +| Option | Description | +|---|---| +| `-c, --config ` | Path to config file | +| `--json` | Print JSON instead of shell exports | + +Examples: + +```sh +pnpm paperclipai worktree env +pnpm paperclipai worktree env --json +eval "$(pnpm paperclipai worktree env)" +``` + For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants. ## Quick Health Checks diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 5f951d69..69d17366 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -58,7 +58,7 @@ From the release worktree: ```bash VERSION=X.Y.Z -claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." +claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." ``` ### 3. Verify and publish a canary @@ -418,5 +418,5 @@ If the release already exists, the script updates it. ## Related Docs - [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals -- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow -- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow +- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow +- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 89f03fb4..56579022 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -22,3 +22,9 @@ export type { CLIAdapterModule, CreateConfigValues, } from "./types.js"; +export { + REDACTED_HOME_PATH_USER, + redactHomePathUserSegments, + redactHomePathUserSegmentsInValue, + redactTranscriptEntryPaths, +} from "./log-redaction.js"; diff --git a/packages/adapter-utils/src/log-redaction.ts b/packages/adapter-utils/src/log-redaction.ts new file mode 100644 index 00000000..037e279e --- /dev/null +++ b/packages/adapter-utils/src/log-redaction.ts @@ -0,0 +1,81 @@ +import type { TranscriptEntry } from "./types.js"; + +export const REDACTED_HOME_PATH_USER = "[]"; + +const HOME_PATH_PATTERNS = [ + { + regex: /\/Users\/[^/\\\s]+/g, + replace: `/Users/${REDACTED_HOME_PATH_USER}`, + }, + { + regex: /\/home\/[^/\\\s]+/g, + replace: `/home/${REDACTED_HOME_PATH_USER}`, + }, + { + regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g, + replace: `$1${REDACTED_HOME_PATH_USER}`, + }, +] as const; + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export function redactHomePathUserSegments(text: string): string { + let result = text; + for (const pattern of HOME_PATH_PATTERNS) { + result = result.replace(pattern.regex, pattern.replace); + } + return result; +} + +export function redactHomePathUserSegmentsInValue(value: T): T { + if (typeof value === "string") { + return redactHomePathUserSegments(value) as T; + } + if (Array.isArray(value)) { + return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T; + } + if (!isPlainObject(value)) { + return value; + } + + const redacted: Record = {}; + for (const [key, entry] of Object.entries(value)) { + redacted[key] = redactHomePathUserSegmentsInValue(entry); + } + return redacted as T; +} + +export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry { + switch (entry.kind) { + case "assistant": + case "thinking": + case "user": + case "stderr": + case "system": + case "stdout": + return { ...entry, text: redactHomePathUserSegments(entry.text) }; + case "tool_call": + return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) }; + case "tool_result": + return { ...entry, content: redactHomePathUserSegments(entry.content) }; + case "init": + return { + ...entry, + model: redactHomePathUserSegments(entry.model), + sessionId: redactHomePathUserSegments(entry.sessionId), + }; + case "result": + return { + ...entry, + text: redactHomePathUserSegments(entry.text), + subtype: redactHomePathUserSegments(entry.subtype), + errors: entry.errors.map((error) => redactHomePathUserSegments(error)), + }; + default: + return entry; + } +} diff --git a/packages/adapters/codex-local/src/ui/parse-stdout.ts b/packages/adapters/codex-local/src/ui/parse-stdout.ts index 7f4028a0..c3151b05 100644 --- a/packages/adapters/codex-local/src/ui/parse-stdout.ts +++ b/packages/adapters/codex-local/src/ui/parse-stdout.ts @@ -1,4 +1,8 @@ -import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { + redactHomePathUserSegments, + redactHomePathUserSegmentsInValue, + type TranscriptEntry, +} from "@paperclipai/adapter-utils"; function safeJsonParse(text: string): unknown { try { @@ -39,12 +43,12 @@ function errorText(value: unknown): string { } function stringifyUnknown(value: unknown): string { - if (typeof value === "string") return value; + if (typeof value === "string") return redactHomePathUserSegments(value); if (value === null || value === undefined) return ""; try { - return JSON.stringify(value, null, 2); + return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2); } catch { - return String(value); + return redactHomePathUserSegments(String(value)); } } @@ -57,7 +61,8 @@ function parseCommandExecutionItem( const command = asString(item.command); const status = asString(item.status); const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null; - const output = asString(item.aggregated_output).replace(/\s+$/, ""); + const safeCommand = redactHomePathUserSegments(command); + const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, ""); if (phase === "started") { return [{ @@ -67,13 +72,13 @@ function parseCommandExecutionItem( toolUseId: id || command || "command_execution", input: { id, - command, + command: safeCommand, }, }]; } const lines: string[] = []; - if (command) lines.push(`command: ${command}`); + if (safeCommand) lines.push(`command: ${safeCommand}`); if (status) lines.push(`status: ${status}`); if (exitCode !== null) lines.push(`exit_code: ${exitCode}`); if (output) { @@ -104,7 +109,7 @@ function parseFileChangeItem(item: Record, ts: string): Transcr .filter((change): change is Record => Boolean(change)) .map((change) => { const kind = asString(change.kind, "update"); - const path = asString(change.path, "unknown"); + const path = redactHomePathUserSegments(asString(change.path, "unknown")); return `${kind} ${path}`; }); @@ -126,13 +131,13 @@ function parseCodexItem( if (itemType === "agent_message") { const text = asString(item.text); - if (text) return [{ kind: "assistant", ts, text }]; + if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }]; return []; } if (itemType === "reasoning") { const text = asString(item.text); - if (text) return [{ kind: "thinking", ts, text }]; + if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }]; return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }]; } @@ -148,9 +153,9 @@ function parseCodexItem( return [{ kind: "tool_call", ts, - name: asString(item.name, "unknown"), + name: redactHomePathUserSegments(asString(item.name, "unknown")), toolUseId: asString(item.id), - input: item.input ?? {}, + input: redactHomePathUserSegmentsInValue(item.input ?? {}), }]; } @@ -162,24 +167,28 @@ function parseCodexItem( asString(item.result) || stringifyUnknown(item.content ?? item.output ?? item.result); const isError = item.is_error === true || asString(item.status) === "error"; - return [{ kind: "tool_result", ts, toolUseId, content, isError }]; + return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }]; } if (itemType === "error" && phase === "completed") { const text = errorText(item.message ?? item.error ?? item); - return [{ kind: "stderr", ts, text: text || "error" }]; + return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }]; } const id = asString(item.id); const status = asString(item.status); const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" "); - return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }]; + return [{ + kind: "system", + ts, + text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`), + }]; } export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] { const parsed = asRecord(safeJsonParse(line)); if (!parsed) { - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; } const type = asString(parsed.type); @@ -189,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "init", ts, - model: asString(parsed.model, "codex"), - sessionId: threadId, + model: redactHomePathUserSegments(asString(parsed.model, "codex")), + sessionId: redactHomePathUserSegments(threadId), }]; } @@ -212,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "result", ts, - text: asString(parsed.result), + text: redactHomePathUserSegments(asString(parsed.result)), inputTokens, outputTokens, cachedTokens, costUsd: asNumber(parsed.total_cost_usd), - subtype: asString(parsed.subtype), + subtype: redactHomePathUserSegments(asString(parsed.subtype)), isError: parsed.is_error === true, errors: Array.isArray(parsed.errors) - ? parsed.errors.map(errorText).filter(Boolean) + ? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean) : [], }]; } @@ -234,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "result", ts, - text: asString(parsed.result), + text: redactHomePathUserSegments(asString(parsed.result)), inputTokens, outputTokens, cachedTokens, costUsd: asNumber(parsed.total_cost_usd), - subtype: asString(parsed.subtype, "turn.failed"), + subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")), isError: true, - errors: message ? [message] : [], + errors: message ? [redactHomePathUserSegments(message)] : [], }]; } if (type === "error") { const message = errorText(parsed.message ?? parsed.error ?? parsed); - return [{ kind: "stderr", ts, text: message || line }]; + return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; } diff --git a/scripts/check-forbidden-tokens.mjs b/scripts/check-forbidden-tokens.mjs index e94fd485..f34faae8 100644 --- a/scripts/check-forbidden-tokens.mjs +++ b/scripts/check-forbidden-tokens.mjs @@ -7,61 +7,109 @@ * working tree (not just staged changes). * * Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok). - * If the file is missing, the check passes silently — other developers - * on the project won't have this list, and that's fine. + * If the file is missing, the check still uses the active local username when + * available. If username detection fails, the check degrades gracefully. */ import { execSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; +import os from "node:os"; import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; -const repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); -const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim(); -const tokensFile = resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"); - -if (!existsSync(tokensFile)) { - console.log(" ℹ Forbidden tokens list not found — skipping check."); - process.exit(0); +function uniqueNonEmpty(values) { + return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean))); } -const tokens = readFileSync(tokensFile, "utf8") - .split("\n") - .map((l) => l.trim()) - .filter((l) => l && !l.startsWith("#")); +export function resolveDynamicForbiddenTokens(env = process.env, osModule = os) { + const candidates = [env.USER, env.LOGNAME, env.USERNAME]; -if (tokens.length === 0) { - console.log(" ℹ Forbidden tokens list is empty — skipping check."); - process.exit(0); -} - -// Use git grep to search tracked files only (avoids node_modules, dist, etc.) -let found = false; - -for (const token of tokens) { try { - const result = execSync( - `git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`, - { encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] }, - ); - if (result.trim()) { - if (!found) { - console.error("ERROR: Forbidden tokens found in tracked files:\n"); - } - found = true; - // Print matches but DO NOT print which token was matched (avoids leaking the list) - const lines = result.trim().split("\n"); - for (const line of lines) { - console.error(` ${line}`); - } - } + candidates.push(osModule.userInfo().username); } catch { - // git grep returns exit code 1 when no matches — that's fine + // Some environments do not expose userInfo; env vars are enough fallback. } + + return uniqueNonEmpty(candidates); } -if (found) { - console.error("\nBuild blocked. Remove the forbidden token(s) before publishing."); - process.exit(1); -} else { - console.log(" ✓ No forbidden tokens found."); +export function readForbiddenTokensFile(tokensFile) { + if (!existsSync(tokensFile)) return []; + + return readFileSync(tokensFile, "utf8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); +} + +export function resolveForbiddenTokens(tokensFile, env = process.env, osModule = os) { + return uniqueNonEmpty([ + ...resolveDynamicForbiddenTokens(env, osModule), + ...readForbiddenTokensFile(tokensFile), + ]); +} + +export function runForbiddenTokenCheck({ + repoRoot, + tokens, + exec = execSync, + log = console.log, + error = console.error, +}) { + if (tokens.length === 0) { + log(" ℹ Forbidden tokens list is empty — skipping check."); + return 0; + } + + let found = false; + + for (const token of tokens) { + try { + const result = exec( + `git grep -in --no-color -- ${JSON.stringify(token)} -- ':!pnpm-lock.yaml' ':!.git'`, + { encoding: "utf8", cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] }, + ); + if (result.trim()) { + if (!found) { + error("ERROR: Forbidden tokens found in tracked files:\n"); + } + found = true; + const lines = result.trim().split("\n"); + for (const line of lines) { + error(` ${line}`); + } + } + } catch { + // git grep returns exit code 1 when no matches — that's fine + } + } + + if (found) { + error("\nBuild blocked. Remove the forbidden token(s) before publishing."); + return 1; + } + + log(" ✓ No forbidden tokens found."); + return 0; +} + +function resolveRepoPaths(exec = execSync) { + const repoRoot = exec("git rev-parse --show-toplevel", { encoding: "utf8" }).trim(); + const gitDir = exec("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim(); + return { + repoRoot, + tokensFile: resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt"), + }; +} + +function main() { + const { repoRoot, tokensFile } = resolveRepoPaths(); + const tokens = resolveForbiddenTokens(tokensFile); + process.exit(runForbiddenTokenCheck({ repoRoot, tokens })); +} + +const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMainModule) { + main(); } diff --git a/server/src/__tests__/codex-local-adapter.test.ts b/server/src/__tests__/codex-local-adapter.test.ts index 07733399..18479e43 100644 --- a/server/src/__tests__/codex-local-adapter.test.ts +++ b/server/src/__tests__/codex-local-adapter.test.ts @@ -107,7 +107,7 @@ describe("codex_local ui stdout parser", () => { item: { id: "item_52", type: "file_change", - changes: [{ path: "/home/user/project/ui/src/pages/AgentDetail.tsx", kind: "update" }], + changes: [{ path: "/Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx", kind: "update" }], status: "completed", }, }), @@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => { { kind: "system", ts, - text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx", + text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx", }, ]); }); diff --git a/server/src/__tests__/forbidden-tokens.test.ts b/server/src/__tests__/forbidden-tokens.test.ts new file mode 100644 index 00000000..0319311a --- /dev/null +++ b/server/src/__tests__/forbidden-tokens.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; + +const { + resolveDynamicForbiddenTokens, + resolveForbiddenTokens, + runForbiddenTokenCheck, +} = await import("../../../scripts/check-forbidden-tokens.mjs"); + +describe("forbidden token check", () => { + it("derives username tokens without relying on whoami", () => { + const tokens = resolveDynamicForbiddenTokens( + { USER: "paperclip", LOGNAME: "paperclip", USERNAME: "pc" }, + { + userInfo: () => ({ username: "paperclip" }), + }, + ); + + expect(tokens).toEqual(["paperclip", "pc"]); + }); + + it("falls back cleanly when user resolution fails", () => { + const tokens = resolveDynamicForbiddenTokens( + {}, + { + userInfo: () => { + throw new Error("missing user"); + }, + }, + ); + + expect(tokens).toEqual([]); + }); + + it("merges dynamic and file-based forbidden tokens", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + + const tokensFile = path.join(os.tmpdir(), `forbidden-tokens-${Date.now()}.txt`); + fs.writeFileSync(tokensFile, "# comment\npaperclip\ncustom-token\n"); + + try { + const tokens = resolveForbiddenTokens(tokensFile, { USER: "paperclip" }, { + userInfo: () => ({ username: "paperclip" }), + }); + + expect(tokens).toEqual(["paperclip", "custom-token"]); + } finally { + fs.unlinkSync(tokensFile); + } + }); + + it("reports matches without leaking which token was searched", () => { + const exec = vi + .fn() + .mockReturnValueOnce("server/file.ts:1:found\n") + .mockImplementation(() => { + throw new Error("not found"); + }); + const log = vi.fn(); + const error = vi.fn(); + + const exitCode = runForbiddenTokenCheck({ + repoRoot: "/repo", + tokens: ["paperclip", "custom-token"], + exec, + log, + error, + }); + + expect(exitCode).toBe(1); + expect(exec).toHaveBeenCalledTimes(2); + expect(error).toHaveBeenCalledWith("ERROR: Forbidden tokens found in tracked files:\n"); + expect(error).toHaveBeenCalledWith(" server/file.ts:1:found"); + expect(error).toHaveBeenCalledWith("\nBuild blocked. Remove the forbidden token(s) before publishing."); + }); +}); diff --git a/server/src/__tests__/log-redaction.test.ts b/server/src/__tests__/log-redaction.test.ts new file mode 100644 index 00000000..a1da7a2e --- /dev/null +++ b/server/src/__tests__/log-redaction.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + CURRENT_USER_REDACTION_TOKEN, + redactCurrentUserText, + redactCurrentUserValue, +} from "../log-redaction.js"; + +describe("log redaction", () => { + it("redacts the active username inside home-directory paths", () => { + const userName = "paperclipuser"; + const input = [ + `cwd=/Users/${userName}/paperclip`, + `home=/home/${userName}/workspace`, + `win=C:\\Users\\${userName}\\paperclip`, + ].join("\n"); + + const result = redactCurrentUserText(input, { + userNames: [userName], + homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`], + }); + + expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`); + expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`); + expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`); + expect(result).not.toContain(userName); + }); + + it("redacts standalone username mentions without mangling larger tokens", () => { + const userName = "paperclipuser"; + const result = redactCurrentUserText( + `user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`, + { + userNames: [userName], + homeDirs: [], + }, + ); + + expect(result).toBe( + `user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`, + ); + }); + + it("recursively redacts nested event payloads", () => { + const userName = "paperclipuser"; + const result = redactCurrentUserValue({ + cwd: `/Users/${userName}/paperclip`, + prompt: `open /Users/${userName}/paperclip/ui`, + nested: { + author: userName, + }, + values: [userName, `/home/${userName}/project`], + }, { + userNames: [userName], + homeDirs: [`/Users/${userName}`, `/home/${userName}`], + }); + + expect(result).toEqual({ + cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`, + prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`, + nested: { + author: CURRENT_USER_REDACTION_TOKEN, + }, + values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`], + }); + }); +}); diff --git a/server/src/log-redaction.ts b/server/src/log-redaction.ts new file mode 100644 index 00000000..07a28c58 --- /dev/null +++ b/server/src/log-redaction.ts @@ -0,0 +1,138 @@ +import os from "node:os"; + +export const CURRENT_USER_REDACTION_TOKEN = "[]"; + +interface CurrentUserRedactionOptions { + replacement?: string; + userNames?: string[]; + homeDirs?: string[]; +} + +type CurrentUserCandidates = { + userNames: string[]; + homeDirs: string[]; + replacement: string; +}; + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function uniqueNonEmpty(values: Array) { + return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean))); +} + +function splitPathSegments(value: string) { + return value.replace(/[\\/]+$/, "").split(/[\\/]+/).filter(Boolean); +} + +function replaceLastPathSegment(pathValue: string, replacement: string) { + const normalized = pathValue.replace(/[\\/]+$/, ""); + const lastSeparator = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + if (lastSeparator < 0) return replacement; + return `${normalized.slice(0, lastSeparator + 1)}${replacement}`; +} + +function defaultUserNames() { + const candidates = [ + process.env.USER, + process.env.LOGNAME, + process.env.USERNAME, + ]; + + try { + candidates.push(os.userInfo().username); + } catch { + // Some environments do not expose userInfo; env vars are enough fallback. + } + + return uniqueNonEmpty(candidates); +} + +function defaultHomeDirs(userNames: string[]) { + const candidates: Array = [ + process.env.HOME, + process.env.USERPROFILE, + ]; + + try { + candidates.push(os.homedir()); + } catch { + // Ignore and fall back to env hints below. + } + + for (const userName of userNames) { + candidates.push(`/Users/${userName}`); + candidates.push(`/home/${userName}`); + candidates.push(`C:\\Users\\${userName}`); + } + + return uniqueNonEmpty(candidates); +} + +let cachedCurrentUserCandidates: CurrentUserCandidates | null = null; + +function getDefaultCurrentUserCandidates(): CurrentUserCandidates { + if (cachedCurrentUserCandidates) return cachedCurrentUserCandidates; + const userNames = defaultUserNames(); + cachedCurrentUserCandidates = { + userNames, + homeDirs: defaultHomeDirs(userNames), + replacement: CURRENT_USER_REDACTION_TOKEN, + }; + return cachedCurrentUserCandidates; +} + +function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) { + const defaults = getDefaultCurrentUserCandidates(); + const userNames = uniqueNonEmpty(opts?.userNames ?? defaults.userNames); + const homeDirs = uniqueNonEmpty(opts?.homeDirs ?? defaults.homeDirs); + const replacement = opts?.replacement?.trim() || defaults.replacement; + return { userNames, homeDirs, replacement }; +} + +export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) { + if (!input) return input; + + const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts); + let result = input; + + for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) { + const lastSegment = splitPathSegments(homeDir).pop() ?? ""; + const replacementDir = userNames.includes(lastSegment) + ? replaceLastPathSegment(homeDir, replacement) + : replacement; + result = result.split(homeDir).join(replacementDir); + } + + for (const userName of [...userNames].sort((a, b) => b.length - a.length)) { + const pattern = new RegExp(`(?(value: T, opts?: CurrentUserRedactionOptions): T { + if (typeof value === "string") { + return redactCurrentUserText(value, opts) as T; + } + if (Array.isArray(value)) { + return value.map((entry) => redactCurrentUserValue(entry, opts)) as T; + } + if (!isPlainObject(value)) { + return value; + } + + const redacted: Record = {}; + for (const [key, entry] of Object.entries(value)) { + redacted[key] = redactCurrentUserValue(entry, opts); + } + return redacted as T; +} diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 91a47d95..c4485d30 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -31,6 +31,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; +import { redactCurrentUserValue } from "../log-redaction.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, @@ -1360,7 +1361,7 @@ export function agentRoutes(db: Db) { return; } assertCompanyAccess(req, run.companyId); - res.json(run); + res.json(redactCurrentUserValue(run)); }); router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { @@ -1395,10 +1396,12 @@ export function agentRoutes(db: Db) { const afterSeq = Number(req.query.afterSeq ?? 0); const limit = Number(req.query.limit ?? 200); const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200); - const redactedEvents = events.map((event) => ({ - ...event, - payload: redactEventPayload(event.payload), - })); + const redactedEvents = events.map((event) => + redactCurrentUserValue({ + ...event, + payload: redactEventPayload(event.payload), + }), + ); res.json(redactedEvents); }); @@ -1495,7 +1498,7 @@ export function agentRoutes(db: Db) { } res.json({ - ...run, + ...redactCurrentUserValue(run), agentId: agent.id, agentName: agent.name, adapterType: agent.adapterType, diff --git a/server/src/services/activity-log.ts b/server/src/services/activity-log.ts index 41cd2938..cdef68ec 100644 --- a/server/src/services/activity-log.ts +++ b/server/src/services/activity-log.ts @@ -1,6 +1,7 @@ import type { Db } from "@paperclipai/db"; import { activityLog } from "@paperclipai/db"; import { publishLiveEvent } from "./live-events.js"; +import { redactCurrentUserValue } from "../log-redaction.js"; import { sanitizeRecord } from "../redaction.js"; export interface LogActivityInput { @@ -17,6 +18,7 @@ export interface LogActivityInput { export async function logActivity(db: Db, input: LogActivityInput) { const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null; + const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null; await db.insert(activityLog).values({ companyId: input.companyId, actorType: input.actorType, @@ -26,7 +28,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { entityId: input.entityId, agentId: input.agentId ?? null, runId: input.runId ?? null, - details: sanitizedDetails, + details: redactedDetails, }); publishLiveEvent({ @@ -40,7 +42,7 @@ export async function logActivity(db: Db, input: LogActivityInput) { entityId: input.entityId, agentId: input.agentId ?? null, runId: input.runId ?? null, - details: sanitizedDetails, + details: redactedDetails, }, }); } diff --git a/server/src/services/approvals.ts b/server/src/services/approvals.ts index 13e57c8a..53833044 100644 --- a/server/src/services/approvals.ts +++ b/server/src/services/approvals.ts @@ -2,9 +2,17 @@ import { and, asc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { approvalComments, approvals } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; +import { redactCurrentUserText } from "../log-redaction.js"; import { agentService } from "./agents.js"; import { notifyHireApproved } from "./hire-hook.js"; +function redactApprovalComment(comment: T): T { + return { + ...comment, + body: redactCurrentUserText(comment.body), + }; +} + export function approvalService(db: Db) { const agentsSvc = agentService(db); const canResolveStatuses = new Set(["pending", "revision_requested"]); @@ -215,7 +223,8 @@ export function approvalService(db: Db) { eq(approvalComments.companyId, existing.companyId), ), ) - .orderBy(asc(approvalComments.createdAt)); + .orderBy(asc(approvalComments.createdAt)) + .then((comments) => comments.map(redactApprovalComment)); }, addComment: async ( @@ -224,6 +233,7 @@ export function approvalService(db: Db) { actor: { agentId?: string; userId?: string }, ) => { const existing = await getExistingApproval(approvalId); + const redactedBody = redactCurrentUserText(body); return db .insert(approvalComments) .values({ @@ -231,10 +241,10 @@ export function approvalService(db: Db) { approvalId, authorAgentId: actor.agentId ?? null, authorUserId: actor.userId ?? null, - body, + body: redactedBody, }) .returning() - .then((rows) => rows[0]); + .then((rows) => redactApprovalComment(rows[0])); }, }; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c3f562e9..e782bc25 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -39,6 +39,7 @@ import { parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, } from "./execution-workspace-policy.js"; +import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -811,6 +812,9 @@ export function heartbeatService(db: Db) { payload?: Record; }, ) { + const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message; + const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload; + await db.insert(heartbeatRunEvents).values({ companyId: run.companyId, runId: run.id, @@ -820,8 +824,8 @@ export function heartbeatService(db: Db) { stream: event.stream, level: event.level, color: event.color, - message: event.message, - payload: event.payload, + message: sanitizedMessage, + payload: sanitizedPayload, }); publishLiveEvent({ @@ -835,8 +839,8 @@ export function heartbeatService(db: Db) { stream: event.stream ?? null, level: event.level ?? null, color: event.color ?? null, - message: event.message ?? null, - payload: event.payload ?? null, + message: sanitizedMessage ?? null, + payload: sanitizedPayload ?? null, }, }); } @@ -1325,22 +1329,23 @@ export function heartbeatService(db: Db) { .where(eq(heartbeatRuns.id, runId)); const onLog = async (stream: "stdout" | "stderr", chunk: string) => { - if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk); - if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk); + const sanitizedChunk = redactCurrentUserText(chunk); + if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); + if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk); const ts = new Date().toISOString(); if (handle) { await runLogStore.append(handle, { stream, - chunk, + chunk: sanitizedChunk, ts, }); } const payloadChunk = - chunk.length > MAX_LIVE_LOG_CHUNK_BYTES - ? chunk.slice(chunk.length - MAX_LIVE_LOG_CHUNK_BYTES) - : chunk; + sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES + ? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES) + : sanitizedChunk; publishLiveEvent({ companyId: run.companyId, @@ -1351,7 +1356,7 @@ export function heartbeatService(db: Db) { ts, stream, chunk: payloadChunk, - truncated: payloadChunk.length !== chunk.length, + truncated: payloadChunk.length !== sanitizedChunk.length, }, }); }; @@ -1542,7 +1547,9 @@ export function heartbeatService(db: Db) { error: outcome === "succeeded" ? null - : adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), + : redactCurrentUserText( + adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"), + ), errorCode: outcome === "timed_out" ? "timeout" @@ -1609,7 +1616,7 @@ export function heartbeatService(db: Db) { } await finalizeAgentStatus(agent.id, outcome); } catch (err) { - const message = err instanceof Error ? err.message : "Unknown adapter failure"; + const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure"); logger.error({ err, runId }, "heartbeat execution failed"); let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null; @@ -2395,6 +2402,7 @@ export function heartbeatService(db: Db) { store: run.logStore, logRef: run.logRef, ...result, + content: redactCurrentUserText(result.content), }; }, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index a25d21fc..29995cd4 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -22,6 +22,7 @@ import { defaultIssueExecutionWorkspaceSettingsForProject, parseProjectExecutionWorkspacePolicy, } from "./execution-workspace-policy.js"; +import { redactCurrentUserText } from "../log-redaction.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; @@ -88,6 +89,13 @@ type IssueUserContextInput = { updatedAt: Date | string; }; +function redactIssueComment(comment: T): T { + return { + ...comment, + body: redactCurrentUserText(comment.body), + }; +} + function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; return checkoutRunId == null; @@ -1041,14 +1049,18 @@ export function issueService(db: Db) { .select() .from(issueComments) .where(eq(issueComments.issueId, issueId)) - .orderBy(desc(issueComments.createdAt)), + .orderBy(desc(issueComments.createdAt)) + .then((comments) => comments.map(redactIssueComment)), getComment: (commentId: string) => db .select() .from(issueComments) .where(eq(issueComments.id, commentId)) - .then((rows) => rows[0] ?? null), + .then((rows) => { + const comment = rows[0] ?? null; + return comment ? redactIssueComment(comment) : null; + }), addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => { const issue = await db @@ -1059,6 +1071,7 @@ export function issueService(db: Db) { if (!issue) throw notFound("Issue not found"); + const redactedBody = redactCurrentUserText(body); const [comment] = await db .insert(issueComments) .values({ @@ -1066,7 +1079,7 @@ export function issueService(db: Db) { issueId, authorAgentId: actor.agentId ?? null, authorUserId: actor.userId ?? null, - body, + body: redactedBody, }) .returning(); @@ -1076,7 +1089,7 @@ export function issueService(db: Db) { .set({ updatedAt: new Date() }) .where(eq(issues.id, issueId)); - return comment; + return redactIssueComment(comment); }, createAttachment: async (input: { diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 394fd999..545c94f4 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -1,3 +1,4 @@ +import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils"; import type { TranscriptEntry, StdoutLineParser } from "./types"; export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; @@ -26,11 +27,11 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser) for (const chunk of chunks) { if (chunk.stream === "stderr") { - entries.push({ kind: "stderr", ts: chunk.ts, text: chunk.chunk }); + entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) }); continue; } if (chunk.stream === "system") { - entries.push({ kind: "system", ts: chunk.ts, text: chunk.chunk }); + entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) }); continue; } @@ -40,14 +41,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser) for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - appendTranscriptEntries(entries, parser(trimmed, chunk.ts)); + appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths)); } } const trailing = stdoutBuffer.trim(); if (trailing) { const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); - appendTranscriptEntries(entries, parser(trailing, ts)); + appendTranscriptEntries(entries, parser(trailing, ts).map(redactTranscriptEntryPaths)); } return entries; diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 5991bb9a..c0883192 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -108,7 +108,7 @@ function AgentRunCard({ ) : ( )} - +
{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`} @@ -147,6 +147,7 @@ function AgentRunCard({ limit={5} streaming={isActive} collapseStdout + thinkingClassName="!text-[10px] !leading-4" emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."} />
diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx new file mode 100644 index 00000000..5de9bf00 --- /dev/null +++ b/ui/src/components/IssueRow.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from "react"; +import type { Issue } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { cn } from "../lib/utils"; +import { PriorityIcon } from "./PriorityIcon"; +import { StatusIcon } from "./StatusIcon"; + +type UnreadState = "hidden" | "visible" | "fading"; + +interface IssueRowProps { + issue: Issue; + issueLinkState?: unknown; + mobileLeading?: ReactNode; + desktopMetaLeading?: ReactNode; + desktopLeadingSpacer?: boolean; + mobileMeta?: ReactNode; + desktopTrailing?: ReactNode; + trailingMeta?: ReactNode; + unreadState?: UnreadState | null; + onMarkRead?: () => void; + className?: string; +} + +export function IssueRow({ + issue, + issueLinkState, + mobileLeading, + desktopMetaLeading, + desktopLeadingSpacer = false, + mobileMeta, + desktopTrailing, + trailingMeta, + unreadState = null, + onMarkRead, + className, +}: IssueRowProps) { + const issuePathId = issue.identifier ?? issue.id; + const identifier = issue.identifier ?? issue.id.slice(0, 8); + const showUnreadSlot = unreadState !== null; + const showUnreadDot = unreadState === "visible" || unreadState === "fading"; + + return ( + + + {mobileLeading ?? } + + + + {issue.title} + + + {desktopLeadingSpacer ? ( + + ) : null} + {desktopMetaLeading ?? ( + <> + + + + + + + + {identifier} + + + )} + {mobileMeta ? ( + <> + + {mobileMeta} + + ) : null} + + + {(desktopTrailing || trailingMeta) ? ( + + {desktopTrailing} + {trailingMeta ? ( + {trailingMeta} + ) : null} + + ) : null} + {showUnreadSlot ? ( + + {showUnreadDot ? ( + + ) : ( + + ) : null} + + ); +} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index da1c161d..6899bd5c 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState, useCallback, useRef } from "react"; -import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; @@ -12,6 +11,7 @@ import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; +import { IssueRow } from "./IssueRow"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -590,162 +590,166 @@ export function IssuesList({ )} {group.items.map((issue) => ( - - {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> - - - {/* Right column on mobile: title + metadata stacked */} - - {/* Title line */} - - {issue.title} + issue={issue} + issueLinkState={issueLinkState} + desktopLeadingSpacer + mobileLeading={( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + onUpdateIssue(issue.id, { status: s })} + /> - - {/* Metadata line */} - - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} - - - { e.preventDefault(); e.stopPropagation(); }}> + )} + desktopMetaLeading={( + <> + + + + { + e.preventDefault(); + e.stopPropagation(); + }} + > onUpdateIssue(issue.id, { status: s })} /> - + {issue.identifier ?? issue.id.slice(0, 8)} {liveIssueIds?.has(issue.id) && ( - + - - + + + + + Live - Live )} - · - - {timeAgo(issue.updatedAt)} - - - - - {/* Desktop-only trailing content */} - - {(issue.labels ?? []).length > 0 && ( - - {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - +{(issue.labels ?? []).length - 3} - )} - - )} - { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} - > - - - - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
+ - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
- -
- - {formatDate(issue.createdAt)} - -
- + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} + > + setAssigneeSearch(e.target.value)} + autoFocus + /> +
+ + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name + .toLowerCase() + .includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+
+ + + )} + trailingMeta={formatDate(issue.createdAt)} + /> ))}
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index e85898a6..01f210ed 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1034,8 +1034,6 @@ export function NewIssueDialog() {
) : createIssue.isError ? ( {createIssueErrorMessage} - ) : canDiscardDraft ? ( - Draft autosaves locally ) : null} )} + - {tab === "all" && ( - <> + {tab === "all" && ( +
)} - - )} -
+ + )} {approvalsError &&

{approvalsError.message}

} @@ -800,64 +810,52 @@ export function Inbox() { <> {showSeparatorBefore("issues_i_touched") && }
-
+
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( - - - {(isUnread || isFading) ? ( - { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - } - }} - className="inline-flex h-4 w-4 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" - aria-label="Mark as read" - > - + issue={issue} + issueLinkState={issueLinkState} + desktopMetaLeading={( + <> + + - ) : ( - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - - {issue.title} - - - - {issue.lastExternalCommentAt + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + mobileMeta={ + issue.lastExternalCommentAt ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - - + : `updated ${timeAgo(issue.updatedAt)}` + } + unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"} + onMarkRead={() => markReadMutation.mutate(issue.id)} + trailingMeta={ + issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}` + } + /> ); })}