Merge pull request #695 from paperclipai/nm/public-master-polish-batch

Polish inbox, transcripts, and log redaction flows
This commit is contained in:
Dotta
2026-03-12 08:13:33 -05:00
committed by GitHub
29 changed files with 1104 additions and 354 deletions

View File

@@ -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.

View File

@@ -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 <name>` | Display name used to derive the instance id |
| `--instance <id>` | Explicit isolated instance id |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
| `--from-instance <id>` | Source instance id (default: `default`) |
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <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 <name> [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 <ref>` | Remote ref to base the new branch on (e.g. `origin/main`) |
| `--instance <id>` | Explicit isolated instance id |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source PAPERCLIP_HOME used when deriving the source config |
| `--from-instance <id>` | Source instance id (default: `default`) |
| `--server-port <port>` | Preferred server port |
| `--db-port <port>` | Preferred embedded Postgres port |
| `--seed-mode <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>` | 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

View File

@@ -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

View File

@@ -22,3 +22,9 @@ export type {
CLIAdapterModule,
CreateConfigValues,
} from "./types.js";
export {
REDACTED_HOME_PATH_USER,
redactHomePathUserSegments,
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";

View File

@@ -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<string, unknown> {
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<T>(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<string, unknown> = {};
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;
}
}

View File

@@ -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<string, unknown>, ts: string): Transcr
.filter((change): change is Record<string, unknown> => 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) }];
}

View File

@@ -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();
}

View File

@@ -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",
},
]);
});

View File

@@ -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.");
});
});

View File

@@ -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`],
});
});
});

138
server/src/log-redaction.ts Normal file
View File

@@ -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<string, unknown> {
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<string | null | undefined>) {
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<string | null | undefined> = [
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(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, replacement);
}
return result;
}
export function redactCurrentUserValue<T>(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<string, unknown> = {};
for (const [key, entry] of Object.entries(value)) {
redacted[key] = redactCurrentUserValue(entry, opts);
}
return redacted as T;
}

View File

@@ -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,

View File

@@ -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,
},
});
}

View File

@@ -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<T extends { body: string }>(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]));
},
};
}

View File

@@ -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<string, unknown>;
},
) {
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),
};
},

View File

@@ -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<T extends { body: string }>(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: {

View File

@@ -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;

View File

@@ -108,7 +108,7 @@ function AgentRunCard({
) : (
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
)}
<Identity name={run.agentName} size="sm" />
<Identity name={run.agentName} size="sm" className="[&>span:last-child]:!text-[11px]" />
</div>
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
@@ -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."}
/>
</div>

View File

@@ -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 (
<Link
to={`/issues/${issuePathId}`}
state={issueLinkState}
className={cn(
"flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
className,
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{desktopLeadingSpacer ? (
<span className="hidden w-3.5 shrink-0 sm:block" />
) : null}
{desktopMetaLeading ?? (
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
</span>
</>
)}
{mobileMeta ? (
<>
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
&middot;
</span>
<span className="text-xs text-muted-foreground sm:hidden">{mobileMeta}</span>
</>
) : null}
</span>
</span>
{(desktopTrailing || trailingMeta) ? (
<span className="ml-auto hidden shrink-0 items-center gap-2 sm:order-3 sm:flex sm:gap-3">
{desktopTrailing}
{trailingMeta ? (
<span className="text-xs text-muted-foreground">{trailingMeta}</span>
) : null}
</span>
) : null}
{showUnreadSlot ? (
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{showUnreadDot ? (
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onMarkRead?.();
}
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20"
aria-label="Mark as read"
>
<span
className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100",
)}
/>
</button>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
) : null}
</Link>
);
}

View File

@@ -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({
)}
<CollapsibleContent>
{group.items.map((issue) => (
<Link
<IssueRow
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1"
>
{/* Status icon - left column on mobile, inline on desktop */}
<span className="shrink-0 pt-px sm:hidden" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
{/* Right column on mobile: title + metadata stacked */}
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
{/* Title line */}
<span className="line-clamp-2 text-sm sm:order-2 sm:flex-1 sm:min-w-0 sm:line-clamp-none sm:truncate">
{issue.title}
issue={issue}
issueLinkState={issueLinkState}
desktopLeadingSpacer
mobileLeading={(
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
{/* Metadata line */}
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
{/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */}
<span className="w-3.5 shrink-0 hidden sm:block" />
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
<span className="hidden shrink-0 sm:inline-flex" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
)}
desktopMetaLeading={(
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="text-xs text-muted-foreground font-mono shrink-0">
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 rounded-full bg-blue-500/10">
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
</span>
)}
<span className="text-xs text-muted-foreground sm:hidden">&middot;</span>
<span className="text-xs text-muted-foreground sm:hidden">
{timeAgo(issue.updatedAt)}
</span>
</span>
</span>
{/* Desktop-only trailing content */}
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto">
{(issue.labels ?? []).length > 0 && (
<span className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: label.color,
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 3}</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 hover:bg-accent/50 transition-colors"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: label.color,
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<PopoverTrigger asChild>
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent"
)}
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null);
}}
>
No assignee
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
issue.assigneeAgentId === agent.id && "bg-accent"
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
</span>
</Link>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search agents..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null);
}}
>
No assignee
</button>
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))}
</CollapsibleContent>
</Collapsible>

View File

@@ -1034,8 +1034,6 @@ export function NewIssueDialog() {
</span>
) : createIssue.isError ? (
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
) : canDiscardDraft ? (
<span className="text-xs text-muted-foreground">Draft autosaves locally</span>
) : null}
</div>
<Button

View File

@@ -0,0 +1,84 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { TranscriptEntry } from "../../adapters";
import { ThemeProvider } from "../../context/ThemeContext";
import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
describe("RunTranscriptView", () => {
it("keeps running command stdout inside the command fold instead of a standalone stdout block", () => {
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts: "2026-03-12T00:00:00.000Z",
name: "command_execution",
toolUseId: "cmd_1",
input: { command: "ls -la" },
},
{
kind: "stdout",
ts: "2026-03-12T00:00:01.000Z",
text: "file-a\nfile-b",
},
];
const blocks = normalizeTranscript(entries, false);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
type: "command_group",
items: [{ result: "file-a\nfile-b", status: "running" }],
});
});
it("renders assistant and thinking content as markdown in compact mode", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<RunTranscriptView
density="compact"
entries={[
{
kind: "assistant",
ts: "2026-03-12T00:00:00.000Z",
text: "Hello **world**",
},
{
kind: "thinking",
ts: "2026-03-12T00:00:01.000Z",
text: "- first\n- second",
},
]}
/>
</ThemeProvider>,
);
expect(html).toContain("<strong>world</strong>");
expect(html).toContain("<li>first</li>");
expect(html).toContain("<li>second</li>");
});
it("hides saved-session resume skip stderr from nice mode normalization", () => {
const entries: TranscriptEntry[] = [
{
kind: "stderr",
ts: "2026-03-12T00:00:00.000Z",
text: "[paperclip] Skipping saved session resume for task \"PAP-485\" because wake reason is issue_assigned.",
},
{
kind: "assistant",
ts: "2026-03-12T00:00:01.000Z",
text: "Working on the task.",
},
];
const blocks = normalizeTranscript(entries, false);
expect(blocks).toHaveLength(1);
expect(blocks[0]).toMatchObject({
type: "message",
role: "assistant",
text: "Working on the task.",
});
});
});

View File

@@ -24,6 +24,7 @@ interface RunTranscriptViewProps {
collapseStdout?: boolean;
emptyMessage?: string;
className?: string;
thinkingClassName?: string;
}
type TranscriptBlock =
@@ -98,16 +99,6 @@ function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}` : value;
}
function stripMarkdown(value: string): string {
return compactWhitespace(
value
.replace(/```[\s\S]*?```/g, " code ")
.replace(/`([^`]+)`/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[*_#>-]/g, " "),
);
}
function humanizeLabel(value: string): string {
return value
.replace(/[_-]+/g, " ")
@@ -285,6 +276,11 @@ function parseSystemActivity(text: string): { activityId?: string; name: string;
};
}
function shouldHideNiceModeStderr(text: string): boolean {
const normalized = compactWhitespace(text).toLowerCase();
return normalized.startsWith("[paperclip] skipping saved session resume");
}
function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
const grouped: TranscriptBlock[] = [];
let pending: Array<Extract<TranscriptBlock, { type: "command_group" }>["items"][number]> = [];
@@ -329,7 +325,7 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
return grouped;
}
function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
const blocks: TranscriptBlock[] = [];
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
const pendingActivityBlocks = new Map<string, Extract<TranscriptBlock, { type: "activity" }>>();
@@ -438,6 +434,9 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
}
if (entry.kind === "stderr") {
if (shouldHideNiceModeStderr(entry.text)) {
continue;
}
blocks.push({
type: "event",
ts: entry.ts,
@@ -486,6 +485,17 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
continue;
}
const activeCommandBlock = [...blocks].reverse().find(
(block): block is Extract<TranscriptBlock, { type: "tool" }> =>
block.type === "tool" && block.status === "running" && isCommandTool(block.name, block.input),
);
if (activeCommandBlock) {
activeCommandBlock.result = activeCommandBlock.result
? `${activeCommandBlock.result}${activeCommandBlock.result.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`}`
: entry.text;
continue;
}
if (previous?.type === "stdout") {
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
previous.ts = entry.ts;
@@ -519,15 +529,14 @@ function TranscriptMessageBlock({
<span>User</span>
</div>
)}
{compact ? (
<div className="text-xs leading-5 text-foreground/85 whitespace-pre-wrap break-words">
{truncate(stripMarkdown(block.text), 360)}
</div>
) : (
<MarkdownBody className="text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{block.text}
</MarkdownBody>
)}
<MarkdownBody
className={cn(
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
)}
>
{block.text}
</MarkdownBody>
{block.streaming && (
<div className="mt-2 inline-flex items-center gap-1 text-[10px] font-medium italic text-muted-foreground">
<span className="relative flex h-1.5 w-1.5">
@@ -544,19 +553,22 @@ function TranscriptMessageBlock({
function TranscriptThinkingBlock({
block,
density,
className,
}: {
block: Extract<TranscriptBlock, { type: "thinking" }>;
density: TranscriptDensity;
className?: string;
}) {
return (
<div
<MarkdownBody
className={cn(
"whitespace-pre-wrap break-words italic text-foreground/70",
"italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6",
className,
)}
>
{block.text}
</div>
</MarkdownBody>
);
}
@@ -956,6 +968,7 @@ export function RunTranscriptView({
collapseStdout = false,
emptyMessage = "No transcript yet.",
className,
thinkingClassName,
}: RunTranscriptViewProps) {
const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]);
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
@@ -985,7 +998,9 @@ export function RunTranscriptView({
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
>
{block.type === "message" && <TranscriptMessageBlock block={block} density={density} />}
{block.type === "thinking" && <TranscriptThinkingBlock block={block} density={density} />}
{block.type === "thinking" && (
<TranscriptThinkingBlock block={block} density={density} className={thinkingClassName} />
)}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "stdout" && (

View File

@@ -59,6 +59,7 @@ import { Input } from "@/components/ui/input";
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
@@ -92,11 +93,11 @@ function redactEnvValue(key: string, value: unknown): string {
}
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
if (typeof value === "string") return redactHomePathUserSegments(value);
try {
return JSON.stringify(value);
return JSON.stringify(redactHomePathUserSegmentsInValue(value));
} catch {
return String(value);
return redactHomePathUserSegments(String(value));
}
}
@@ -2023,7 +2024,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const adapterInvokePayload = useMemo(() => {
const evt = events.find((e) => e.eventType === "adapter.invoke");
return asRecord(evt?.payload ?? null);
return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null));
}, [events]);
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@@ -2096,8 +2097,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string"
? adapterInvokePayload.prompt
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
? redactHomePathUserSegments(adapterInvokePayload.prompt)
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
</pre>
</div>
)}
@@ -2105,7 +2106,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(adapterInvokePayload.context, null, 2)}
{JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)}
</pre>
</div>
)}
@@ -2189,14 +2190,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.error && (
<div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span>
{run.error}
{redactHomePathUserSegments(run.error)}
</div>
)}
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stderrExcerpt}
{redactHomePathUserSegments(run.stderrExcerpt)}
</pre>
</div>
)}
@@ -2204,7 +2205,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{JSON.stringify(run.resultJson, null, 2)}
{JSON.stringify(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)}
</pre>
</div>
)}
@@ -2212,7 +2213,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{run.stdoutExcerpt}
{redactHomePathUserSegments(run.stdoutExcerpt)}
</pre>
</div>
)}
@@ -2238,7 +2239,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{evt.stream ? `[${evt.stream}]` : ""}
</span>
<span className={cn("break-all", color)}>
{evt.message ?? (evt.payload ? JSON.stringify(evt.payload) : "")}
{evt.message
? redactHomePathUserSegments(evt.message)
: evt.payload
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload))
: ""}
</span>
</div>
);

View File

@@ -12,11 +12,12 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { ApprovalCard } from "../components/ApprovalCard";
import { IssueRow } from "../components/IssueRow";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusIcon } from "../components/StatusIcon";
import { StatusBadge } from "../components/StatusBadge";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
@@ -350,6 +351,15 @@ export function Inbox() {
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
[heartbeatRuns, dismissed],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of heartbeatRuns ?? []) {
if (run.status !== "running" && run.status !== "queued") continue;
const issueId = readIssueIdFromRun(run);
if (issueId) ids.add(issueId);
}
return ids;
}, [heartbeatRuns]);
const allApprovals = useMemo(
() =>
@@ -546,36 +556,37 @@ export function Inbox() {
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<PageTabBar
items={[
{
value: "recent",
label: "Recent",
},
{ value: "unread", label: "Unread" },
{ value: "all", label: "All" },
]}
/>
</Tabs>
{canMarkAllRead && (
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
className="h-8 shrink-0"
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
disabled={markAllReadMutation.isPending}
>
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
</Button>
)}
</div>
{tab === "all" && (
<>
{tab === "all" && (
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
@@ -608,9 +619,8 @@ export function Inbox() {
</SelectContent>
</Select>
)}
</>
)}
</div>
</div>
)}
</div>
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
@@ -800,64 +810,52 @@ export function Inbox() {
<>
{showSeparatorBefore("issues_i_touched") && <Separator />}
<div>
<div className="divide-y divide-border border border-border">
<div>
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
const isFading = fadingOutIssues.has(issue.id);
return (
<Link
<IssueRow
key={issue.id}
to={`/issues/${issue.identifier ?? issue.id}`}
state={issueLinkState}
className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4"
>
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
{(isUnread || isFading) ? (
<span
role="button"
tabIndex={0}
onClick={(e) => {
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"
>
<span
className={`block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400 transition-opacity duration-300 ${
isFading ? "opacity-0" : "opacity-100"
}`}
/>
issue={issue}
issueLinkState={issueLinkState}
desktopMetaLeading={(
<>
<span className="hidden sm:inline-flex">
<PriorityIcon priority={issue.priority} />
</span>
) : (
<span className="inline-flex h-4 w-4" aria-hidden="true" />
)}
</span>
<span className="inline-flex shrink-0 self-center"><PriorityIcon priority={issue.priority} /></span>
<span className="inline-flex shrink-0 self-center"><StatusIcon status={issue.status} /></span>
<span className="shrink-0 self-center text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
<span className="min-w-0 flex-1 text-sm">
<span className="line-clamp-2 min-w-0 sm:line-clamp-1 sm:block sm:truncate">
{issue.title}
</span>
</span>
<span className="hidden shrink-0 self-center text-xs text-muted-foreground sm:block">
{issue.lastExternalCommentAt
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={
issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
: `updated ${timeAgo(issue.updatedAt)}`}
</span>
</Link>
: `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)}`
}
/>
);
})}
</div>