Merge pull request #695 from paperclipai/nm/public-master-polish-batch
Polish inbox, transcripts, and log redaction flows
This commit is contained in:
@@ -33,7 +33,7 @@ Use this skill when leadership asks for:
|
|||||||
|
|
||||||
Before proceeding, verify all of the following:
|
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.
|
2. The repo working tree is clean, including untracked files.
|
||||||
3. There are commits since the last stable tag.
|
3. There are commits since the last stable tag.
|
||||||
4. The release SHA has passed the verification gate or is about to.
|
4. The release SHA has passed the verification gate or is about to.
|
||||||
@@ -162,17 +162,73 @@ paperclipai worktree env
|
|||||||
eval "$(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
|
```sh
|
||||||
paperclipai worktree init --no-seed
|
paperclipai worktree init --no-seed
|
||||||
paperclipai worktree init --seed-mode minimal
|
|
||||||
paperclipai worktree init --seed-mode full
|
paperclipai worktree init --seed-mode full
|
||||||
paperclipai worktree init --from-instance default
|
paperclipai worktree init --from-instance default
|
||||||
paperclipai worktree init --from-data-dir ~/.paperclip
|
paperclipai worktree init --from-data-dir ~/.paperclip
|
||||||
paperclipai worktree init --force
|
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.
|
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
|
## Quick Health Checks
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ From the release worktree:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
VERSION=X.Y.Z
|
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
|
### 3. Verify and publish a canary
|
||||||
@@ -418,5 +418,5 @@ If the release already exists, the script updates it.
|
|||||||
## Related Docs
|
## Related Docs
|
||||||
|
|
||||||
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
|
- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals
|
||||||
- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow
|
- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow
|
||||||
- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow
|
- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow
|
||||||
|
|||||||
@@ -22,3 +22,9 @@ export type {
|
|||||||
CLIAdapterModule,
|
CLIAdapterModule,
|
||||||
CreateConfigValues,
|
CreateConfigValues,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
export {
|
||||||
|
REDACTED_HOME_PATH_USER,
|
||||||
|
redactHomePathUserSegments,
|
||||||
|
redactHomePathUserSegmentsInValue,
|
||||||
|
redactTranscriptEntryPaths,
|
||||||
|
} from "./log-redaction.js";
|
||||||
|
|||||||
81
packages/adapter-utils/src/log-redaction.ts
Normal file
81
packages/adapter-utils/src/log-redaction.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
function safeJsonParse(text: string): unknown {
|
||||||
try {
|
try {
|
||||||
@@ -39,12 +43,12 @@ function errorText(value: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stringifyUnknown(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 "";
|
if (value === null || value === undefined) return "";
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value, null, 2);
|
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return redactHomePathUserSegments(String(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +61,8 @@ function parseCommandExecutionItem(
|
|||||||
const command = asString(item.command);
|
const command = asString(item.command);
|
||||||
const status = asString(item.status);
|
const status = asString(item.status);
|
||||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
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") {
|
if (phase === "started") {
|
||||||
return [{
|
return [{
|
||||||
@@ -67,13 +72,13 @@ function parseCommandExecutionItem(
|
|||||||
toolUseId: id || command || "command_execution",
|
toolUseId: id || command || "command_execution",
|
||||||
input: {
|
input: {
|
||||||
id,
|
id,
|
||||||
command,
|
command: safeCommand,
|
||||||
},
|
},
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (command) lines.push(`command: ${command}`);
|
if (safeCommand) lines.push(`command: ${safeCommand}`);
|
||||||
if (status) lines.push(`status: ${status}`);
|
if (status) lines.push(`status: ${status}`);
|
||||||
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
||||||
if (output) {
|
if (output) {
|
||||||
@@ -104,7 +109,7 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
|
|||||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
.filter((change): change is Record<string, unknown> => Boolean(change))
|
||||||
.map((change) => {
|
.map((change) => {
|
||||||
const kind = asString(change.kind, "update");
|
const kind = asString(change.kind, "update");
|
||||||
const path = asString(change.path, "unknown");
|
const path = redactHomePathUserSegments(asString(change.path, "unknown"));
|
||||||
return `${kind} ${path}`;
|
return `${kind} ${path}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,13 +131,13 @@ function parseCodexItem(
|
|||||||
|
|
||||||
if (itemType === "agent_message") {
|
if (itemType === "agent_message") {
|
||||||
const text = asString(item.text);
|
const text = asString(item.text);
|
||||||
if (text) return [{ kind: "assistant", ts, text }];
|
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemType === "reasoning") {
|
if (itemType === "reasoning") {
|
||||||
const text = asString(item.text);
|
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" }];
|
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +153,9 @@ function parseCodexItem(
|
|||||||
return [{
|
return [{
|
||||||
kind: "tool_call",
|
kind: "tool_call",
|
||||||
ts,
|
ts,
|
||||||
name: asString(item.name, "unknown"),
|
name: redactHomePathUserSegments(asString(item.name, "unknown")),
|
||||||
toolUseId: asString(item.id),
|
toolUseId: asString(item.id),
|
||||||
input: item.input ?? {},
|
input: redactHomePathUserSegmentsInValue(item.input ?? {}),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,24 +167,28 @@ function parseCodexItem(
|
|||||||
asString(item.result) ||
|
asString(item.result) ||
|
||||||
stringifyUnknown(item.content ?? item.output ?? item.result);
|
stringifyUnknown(item.content ?? item.output ?? item.result);
|
||||||
const isError = item.is_error === true || asString(item.status) === "error";
|
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") {
|
if (itemType === "error" && phase === "completed") {
|
||||||
const text = errorText(item.message ?? item.error ?? item);
|
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 id = asString(item.id);
|
||||||
const status = asString(item.status);
|
const status = asString(item.status);
|
||||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
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[] {
|
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
const parsed = asRecord(safeJsonParse(line));
|
const parsed = asRecord(safeJsonParse(line));
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = asString(parsed.type);
|
const type = asString(parsed.type);
|
||||||
@@ -189,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
|||||||
return [{
|
return [{
|
||||||
kind: "init",
|
kind: "init",
|
||||||
ts,
|
ts,
|
||||||
model: asString(parsed.model, "codex"),
|
model: redactHomePathUserSegments(asString(parsed.model, "codex")),
|
||||||
sessionId: threadId,
|
sessionId: redactHomePathUserSegments(threadId),
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[
|
|||||||
return [{
|
return [{
|
||||||
kind: "result",
|
kind: "result",
|
||||||
ts,
|
ts,
|
||||||
text: asString(parsed.result),
|
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cachedTokens,
|
cachedTokens,
|
||||||
costUsd: asNumber(parsed.total_cost_usd),
|
costUsd: asNumber(parsed.total_cost_usd),
|
||||||
subtype: asString(parsed.subtype),
|
subtype: redactHomePathUserSegments(asString(parsed.subtype)),
|
||||||
isError: parsed.is_error === true,
|
isError: parsed.is_error === true,
|
||||||
errors: Array.isArray(parsed.errors)
|
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 [{
|
return [{
|
||||||
kind: "result",
|
kind: "result",
|
||||||
ts,
|
ts,
|
||||||
text: asString(parsed.result),
|
text: redactHomePathUserSegments(asString(parsed.result)),
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cachedTokens,
|
cachedTokens,
|
||||||
costUsd: asNumber(parsed.total_cost_usd),
|
costUsd: asNumber(parsed.total_cost_usd),
|
||||||
subtype: asString(parsed.subtype, "turn.failed"),
|
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
|
||||||
isError: true,
|
isError: true,
|
||||||
errors: message ? [message] : [],
|
errors: message ? [redactHomePathUserSegments(message)] : [],
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "error") {
|
if (type === "error") {
|
||||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
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) }];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,61 +7,109 @@
|
|||||||
* working tree (not just staged changes).
|
* working tree (not just staged changes).
|
||||||
*
|
*
|
||||||
* Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok).
|
* Token list: .git/hooks/forbidden-tokens.txt (one per line, # comments ok).
|
||||||
* If the file is missing, the check passes silently — other developers
|
* If the file is missing, the check still uses the active local username when
|
||||||
* on the project won't have this list, and that's fine.
|
* available. If username detection fails, the check degrades gracefully.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const repoRoot = execSync("git rev-parse --show-toplevel", { encoding: "utf8" }).trim();
|
function uniqueNonEmpty(values) {
|
||||||
const gitDir = execSync("git rev-parse --git-dir", { encoding: "utf8", cwd: repoRoot }).trim();
|
return Array.from(new Set(values.map((value) => value?.trim() ?? "").filter(Boolean)));
|
||||||
const tokensFile = resolve(repoRoot, gitDir, "hooks/forbidden-tokens.txt");
|
|
||||||
|
|
||||||
if (!existsSync(tokensFile)) {
|
|
||||||
console.log(" ℹ Forbidden tokens list not found — skipping check.");
|
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = readFileSync(tokensFile, "utf8")
|
export function resolveDynamicForbiddenTokens(env = process.env, osModule = os) {
|
||||||
.split("\n")
|
const candidates = [env.USER, env.LOGNAME, env.USERNAME];
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l && !l.startsWith("#"));
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const result = execSync(
|
candidates.push(osModule.userInfo().username);
|
||||||
`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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} 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) {
|
export function readForbiddenTokensFile(tokensFile) {
|
||||||
console.error("\nBuild blocked. Remove the forbidden token(s) before publishing.");
|
if (!existsSync(tokensFile)) return [];
|
||||||
process.exit(1);
|
|
||||||
} else {
|
return readFileSync(tokensFile, "utf8")
|
||||||
console.log(" ✓ No forbidden tokens found.");
|
.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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ describe("codex_local ui stdout parser", () => {
|
|||||||
item: {
|
item: {
|
||||||
id: "item_52",
|
id: "item_52",
|
||||||
type: "file_change",
|
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",
|
status: "completed",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
|
|||||||
{
|
{
|
||||||
kind: "system",
|
kind: "system",
|
||||||
ts,
|
ts,
|
||||||
text: "file changes: update /home/user/project/ui/src/pages/AgentDetail.tsx",
|
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
77
server/src/__tests__/forbidden-tokens.test.ts
Normal file
77
server/src/__tests__/forbidden-tokens.test.ts
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
66
server/src/__tests__/log-redaction.test.ts
Normal file
66
server/src/__tests__/log-redaction.test.ts
Normal 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
138
server/src/log-redaction.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
|||||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||||
import { redactEventPayload } from "../redaction.js";
|
import { redactEventPayload } from "../redaction.js";
|
||||||
|
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||||
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
|
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
@@ -1360,7 +1361,7 @@ export function agentRoutes(db: Db) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, run.companyId);
|
assertCompanyAccess(req, run.companyId);
|
||||||
res.json(run);
|
res.json(redactCurrentUserValue(run));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
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 afterSeq = Number(req.query.afterSeq ?? 0);
|
||||||
const limit = Number(req.query.limit ?? 200);
|
const limit = Number(req.query.limit ?? 200);
|
||||||
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
|
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
|
||||||
const redactedEvents = events.map((event) => ({
|
const redactedEvents = events.map((event) =>
|
||||||
...event,
|
redactCurrentUserValue({
|
||||||
payload: redactEventPayload(event.payload),
|
...event,
|
||||||
}));
|
payload: redactEventPayload(event.payload),
|
||||||
|
}),
|
||||||
|
);
|
||||||
res.json(redactedEvents);
|
res.json(redactedEvents);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1495,7 +1498,7 @@ export function agentRoutes(db: Db) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
...run,
|
...redactCurrentUserValue(run),
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
adapterType: agent.adapterType,
|
adapterType: agent.adapterType,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { activityLog } from "@paperclipai/db";
|
import { activityLog } from "@paperclipai/db";
|
||||||
import { publishLiveEvent } from "./live-events.js";
|
import { publishLiveEvent } from "./live-events.js";
|
||||||
|
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||||
import { sanitizeRecord } from "../redaction.js";
|
import { sanitizeRecord } from "../redaction.js";
|
||||||
|
|
||||||
export interface LogActivityInput {
|
export interface LogActivityInput {
|
||||||
@@ -17,6 +18,7 @@ export interface LogActivityInput {
|
|||||||
|
|
||||||
export async function logActivity(db: Db, input: LogActivityInput) {
|
export async function logActivity(db: Db, input: LogActivityInput) {
|
||||||
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
|
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
|
||||||
|
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
|
||||||
await db.insert(activityLog).values({
|
await db.insert(activityLog).values({
|
||||||
companyId: input.companyId,
|
companyId: input.companyId,
|
||||||
actorType: input.actorType,
|
actorType: input.actorType,
|
||||||
@@ -26,7 +28,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
|||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
agentId: input.agentId ?? null,
|
agentId: input.agentId ?? null,
|
||||||
runId: input.runId ?? null,
|
runId: input.runId ?? null,
|
||||||
details: sanitizedDetails,
|
details: redactedDetails,
|
||||||
});
|
});
|
||||||
|
|
||||||
publishLiveEvent({
|
publishLiveEvent({
|
||||||
@@ -40,7 +42,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
|
|||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
agentId: input.agentId ?? null,
|
agentId: input.agentId ?? null,
|
||||||
runId: input.runId ?? null,
|
runId: input.runId ?? null,
|
||||||
details: sanitizedDetails,
|
details: redactedDetails,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import { and, asc, eq, inArray } from "drizzle-orm";
|
|||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { approvalComments, approvals } from "@paperclipai/db";
|
import { approvalComments, approvals } from "@paperclipai/db";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
|
import { redactCurrentUserText } from "../log-redaction.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
import { notifyHireApproved } from "./hire-hook.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) {
|
export function approvalService(db: Db) {
|
||||||
const agentsSvc = agentService(db);
|
const agentsSvc = agentService(db);
|
||||||
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
const canResolveStatuses = new Set(["pending", "revision_requested"]);
|
||||||
@@ -215,7 +223,8 @@ export function approvalService(db: Db) {
|
|||||||
eq(approvalComments.companyId, existing.companyId),
|
eq(approvalComments.companyId, existing.companyId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(approvalComments.createdAt));
|
.orderBy(asc(approvalComments.createdAt))
|
||||||
|
.then((comments) => comments.map(redactApprovalComment));
|
||||||
},
|
},
|
||||||
|
|
||||||
addComment: async (
|
addComment: async (
|
||||||
@@ -224,6 +233,7 @@ export function approvalService(db: Db) {
|
|||||||
actor: { agentId?: string; userId?: string },
|
actor: { agentId?: string; userId?: string },
|
||||||
) => {
|
) => {
|
||||||
const existing = await getExistingApproval(approvalId);
|
const existing = await getExistingApproval(approvalId);
|
||||||
|
const redactedBody = redactCurrentUserText(body);
|
||||||
return db
|
return db
|
||||||
.insert(approvalComments)
|
.insert(approvalComments)
|
||||||
.values({
|
.values({
|
||||||
@@ -231,10 +241,10 @@ export function approvalService(db: Db) {
|
|||||||
approvalId,
|
approvalId,
|
||||||
authorAgentId: actor.agentId ?? null,
|
authorAgentId: actor.agentId ?? null,
|
||||||
authorUserId: actor.userId ?? null,
|
authorUserId: actor.userId ?? null,
|
||||||
body,
|
body: redactedBody,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
.then((rows) => rows[0]);
|
.then((rows) => redactApprovalComment(rows[0]));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
resolveExecutionWorkspaceMode,
|
resolveExecutionWorkspaceMode,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
|
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
|
||||||
|
|
||||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||||
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1;
|
||||||
@@ -811,6 +812,9 @@ export function heartbeatService(db: Db) {
|
|||||||
payload?: Record<string, unknown>;
|
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({
|
await db.insert(heartbeatRunEvents).values({
|
||||||
companyId: run.companyId,
|
companyId: run.companyId,
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
@@ -820,8 +824,8 @@ export function heartbeatService(db: Db) {
|
|||||||
stream: event.stream,
|
stream: event.stream,
|
||||||
level: event.level,
|
level: event.level,
|
||||||
color: event.color,
|
color: event.color,
|
||||||
message: event.message,
|
message: sanitizedMessage,
|
||||||
payload: event.payload,
|
payload: sanitizedPayload,
|
||||||
});
|
});
|
||||||
|
|
||||||
publishLiveEvent({
|
publishLiveEvent({
|
||||||
@@ -835,8 +839,8 @@ export function heartbeatService(db: Db) {
|
|||||||
stream: event.stream ?? null,
|
stream: event.stream ?? null,
|
||||||
level: event.level ?? null,
|
level: event.level ?? null,
|
||||||
color: event.color ?? null,
|
color: event.color ?? null,
|
||||||
message: event.message ?? null,
|
message: sanitizedMessage ?? null,
|
||||||
payload: event.payload ?? null,
|
payload: sanitizedPayload ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1325,22 +1329,23 @@ export function heartbeatService(db: Db) {
|
|||||||
.where(eq(heartbeatRuns.id, runId));
|
.where(eq(heartbeatRuns.id, runId));
|
||||||
|
|
||||||
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||||
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, chunk);
|
const sanitizedChunk = redactCurrentUserText(chunk);
|
||||||
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, chunk);
|
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
|
||||||
|
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
|
||||||
const ts = new Date().toISOString();
|
const ts = new Date().toISOString();
|
||||||
|
|
||||||
if (handle) {
|
if (handle) {
|
||||||
await runLogStore.append(handle, {
|
await runLogStore.append(handle, {
|
||||||
stream,
|
stream,
|
||||||
chunk,
|
chunk: sanitizedChunk,
|
||||||
ts,
|
ts,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadChunk =
|
const payloadChunk =
|
||||||
chunk.length > MAX_LIVE_LOG_CHUNK_BYTES
|
sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
|
||||||
? chunk.slice(chunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
|
? sanitizedChunk.slice(sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES)
|
||||||
: chunk;
|
: sanitizedChunk;
|
||||||
|
|
||||||
publishLiveEvent({
|
publishLiveEvent({
|
||||||
companyId: run.companyId,
|
companyId: run.companyId,
|
||||||
@@ -1351,7 +1356,7 @@ export function heartbeatService(db: Db) {
|
|||||||
ts,
|
ts,
|
||||||
stream,
|
stream,
|
||||||
chunk: payloadChunk,
|
chunk: payloadChunk,
|
||||||
truncated: payloadChunk.length !== chunk.length,
|
truncated: payloadChunk.length !== sanitizedChunk.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1542,7 +1547,9 @@ export function heartbeatService(db: Db) {
|
|||||||
error:
|
error:
|
||||||
outcome === "succeeded"
|
outcome === "succeeded"
|
||||||
? null
|
? null
|
||||||
: adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
|
: redactCurrentUserText(
|
||||||
|
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
|
||||||
|
),
|
||||||
errorCode:
|
errorCode:
|
||||||
outcome === "timed_out"
|
outcome === "timed_out"
|
||||||
? "timeout"
|
? "timeout"
|
||||||
@@ -1609,7 +1616,7 @@ export function heartbeatService(db: Db) {
|
|||||||
}
|
}
|
||||||
await finalizeAgentStatus(agent.id, outcome);
|
await finalizeAgentStatus(agent.id, outcome);
|
||||||
} catch (err) {
|
} 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");
|
logger.error({ err, runId }, "heartbeat execution failed");
|
||||||
|
|
||||||
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
|
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
|
||||||
@@ -2395,6 +2402,7 @@ export function heartbeatService(db: Db) {
|
|||||||
store: run.logStore,
|
store: run.logStore,
|
||||||
logRef: run.logRef,
|
logRef: run.logRef,
|
||||||
...result,
|
...result,
|
||||||
|
content: redactCurrentUserText(result.content),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||||
parseProjectExecutionWorkspacePolicy,
|
parseProjectExecutionWorkspacePolicy,
|
||||||
} from "./execution-workspace-policy.js";
|
} from "./execution-workspace-policy.js";
|
||||||
|
import { redactCurrentUserText } from "../log-redaction.js";
|
||||||
|
|
||||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||||
|
|
||||||
@@ -88,6 +89,13 @@ type IssueUserContextInput = {
|
|||||||
updatedAt: Date | string;
|
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) {
|
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
|
||||||
if (actorRunId) return checkoutRunId === actorRunId;
|
if (actorRunId) return checkoutRunId === actorRunId;
|
||||||
return checkoutRunId == null;
|
return checkoutRunId == null;
|
||||||
@@ -1041,14 +1049,18 @@ export function issueService(db: Db) {
|
|||||||
.select()
|
.select()
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(eq(issueComments.issueId, issueId))
|
.where(eq(issueComments.issueId, issueId))
|
||||||
.orderBy(desc(issueComments.createdAt)),
|
.orderBy(desc(issueComments.createdAt))
|
||||||
|
.then((comments) => comments.map(redactIssueComment)),
|
||||||
|
|
||||||
getComment: (commentId: string) =>
|
getComment: (commentId: string) =>
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(issueComments)
|
.from(issueComments)
|
||||||
.where(eq(issueComments.id, commentId))
|
.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 }) => {
|
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
|
||||||
const issue = await db
|
const issue = await db
|
||||||
@@ -1059,6 +1071,7 @@ export function issueService(db: Db) {
|
|||||||
|
|
||||||
if (!issue) throw notFound("Issue not found");
|
if (!issue) throw notFound("Issue not found");
|
||||||
|
|
||||||
|
const redactedBody = redactCurrentUserText(body);
|
||||||
const [comment] = await db
|
const [comment] = await db
|
||||||
.insert(issueComments)
|
.insert(issueComments)
|
||||||
.values({
|
.values({
|
||||||
@@ -1066,7 +1079,7 @@ export function issueService(db: Db) {
|
|||||||
issueId,
|
issueId,
|
||||||
authorAgentId: actor.agentId ?? null,
|
authorAgentId: actor.agentId ?? null,
|
||||||
authorUserId: actor.userId ?? null,
|
authorUserId: actor.userId ?? null,
|
||||||
body,
|
body: redactedBody,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -1076,7 +1089,7 @@ export function issueService(db: Db) {
|
|||||||
.set({ updatedAt: new Date() })
|
.set({ updatedAt: new Date() })
|
||||||
.where(eq(issues.id, issueId));
|
.where(eq(issues.id, issueId));
|
||||||
|
|
||||||
return comment;
|
return redactIssueComment(comment);
|
||||||
},
|
},
|
||||||
|
|
||||||
createAttachment: async (input: {
|
createAttachment: async (input: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
|
||||||
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
import type { TranscriptEntry, StdoutLineParser } from "./types";
|
||||||
|
|
||||||
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
|
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) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.stream === "stderr") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (chunk.stream === "system") {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,14 +41,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
|
|||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
appendTranscriptEntries(entries, parser(trimmed, chunk.ts));
|
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trailing = stdoutBuffer.trim();
|
const trailing = stdoutBuffer.trim();
|
||||||
if (trailing) {
|
if (trailing) {
|
||||||
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
|
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;
|
return entries;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ function AgentRunCard({
|
|||||||
) : (
|
) : (
|
||||||
<span className="inline-flex h-2.5 w-2.5 rounded-full bg-muted-foreground/35" />
|
<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>
|
||||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
<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>
|
<span>{isActive ? "Live now" : run.finishedAt ? `Finished ${relativeTime(run.finishedAt)}` : `Started ${relativeTime(run.createdAt)}`}</span>
|
||||||
@@ -147,6 +147,7 @@ function AgentRunCard({
|
|||||||
limit={5}
|
limit={5}
|
||||||
streaming={isActive}
|
streaming={isActive}
|
||||||
collapseStdout
|
collapseStdout
|
||||||
|
thinkingClassName="!text-[10px] !leading-4"
|
||||||
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
|
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
127
ui/src/components/IssueRow.tsx
Normal file
127
ui/src/components/IssueRow.tsx
Normal 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">
|
||||||
|
·
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { Link } from "@/lib/router";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
@@ -12,6 +11,7 @@ import { StatusIcon } from "./StatusIcon";
|
|||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
|
import { IssueRow } from "./IssueRow";
|
||||||
import { PageSkeleton } from "./PageSkeleton";
|
import { PageSkeleton } from "./PageSkeleton";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -590,162 +590,166 @@ export function IssuesList({
|
|||||||
)}
|
)}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
{group.items.map((issue) => (
|
{group.items.map((issue) => (
|
||||||
<Link
|
<IssueRow
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
issue={issue}
|
||||||
state={issueLinkState}
|
issueLinkState={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"
|
desktopLeadingSpacer
|
||||||
>
|
mobileLeading={(
|
||||||
{/* Status icon - left column on mobile, inline on desktop */}
|
<span
|
||||||
<span className="shrink-0 pt-px sm:hidden" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
onClick={(e) => {
|
||||||
<StatusIcon
|
e.preventDefault();
|
||||||
status={issue.status}
|
e.stopPropagation();
|
||||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
}}
|
||||||
/>
|
>
|
||||||
</span>
|
<StatusIcon
|
||||||
|
status={issue.status}
|
||||||
{/* Right column on mobile: title + metadata stacked */}
|
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||||
<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}
|
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
{/* Metadata line */}
|
desktopMetaLeading={(
|
||||||
<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="hidden sm:inline-flex">
|
||||||
<span className="w-3.5 shrink-0 hidden sm:block" />
|
<PriorityIcon priority={issue.priority} />
|
||||||
<span className="hidden sm:inline-flex"><PriorityIcon priority={issue.priority} /></span>
|
</span>
|
||||||
<span className="hidden shrink-0 sm:inline-flex" onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
<span
|
||||||
|
className="hidden shrink-0 sm:inline-flex"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<StatusIcon
|
<StatusIcon
|
||||||
status={issue.status}
|
status={issue.status}
|
||||||
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
|
||||||
/>
|
/>
|
||||||
</span>
|
</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)}
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
{liveIssueIds?.has(issue.id) && (
|
{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="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="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500" />
|
<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>
|
||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400 hidden sm:inline">Live</span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground sm:hidden">·</span>
|
</>
|
||||||
<span className="text-xs text-muted-foreground sm:hidden">
|
)}
|
||||||
{timeAgo(issue.updatedAt)}
|
mobileMeta={timeAgo(issue.updatedAt)}
|
||||||
</span>
|
desktopTrailing={(
|
||||||
</span>
|
<>
|
||||||
</span>
|
{(issue.labels ?? []).length > 0 && (
|
||||||
|
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
|
||||||
{/* Desktop-only trailing content */}
|
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
||||||
<span className="hidden sm:flex sm:order-3 items-center gap-2 sm:gap-3 shrink-0 ml-auto">
|
<span
|
||||||
{(issue.labels ?? []).length > 0 && (
|
key={label.id}
|
||||||
<span className="hidden md:flex items-center gap-1 max-w-[240px] overflow-hidden">
|
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
||||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
style={{
|
||||||
<span
|
borderColor: label.color,
|
||||||
key={label.id}
|
color: label.color,
|
||||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
backgroundColor: `${label.color}1f`,
|
||||||
style={{
|
}}
|
||||||
borderColor: label.color,
|
>
|
||||||
color: label.color,
|
{label.name}
|
||||||
backgroundColor: `${label.color}1f`,
|
</span>
|
||||||
}}
|
))}
|
||||||
>
|
{(issue.labels ?? []).length > 3 && (
|
||||||
{label.name}
|
<span className="text-[10px] text-muted-foreground">
|
||||||
</span>
|
+{(issue.labels ?? []).length - 3}
|
||||||
))}
|
|
||||||
{(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
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</span>
|
||||||
</PopoverTrigger>
|
)}
|
||||||
<PopoverContent
|
<Popover
|
||||||
className="w-56 p-1"
|
open={assigneePickerIssueId === issue.id}
|
||||||
align="end"
|
onOpenChange={(open) => {
|
||||||
onClick={(e) => e.stopPropagation()}
|
setAssigneePickerIssueId(open ? issue.id : null);
|
||||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
if (!open) setAssigneeSearch("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<PopoverTrigger asChild>
|
||||||
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">
|
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
||||||
!issue.assigneeAgentId && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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>
|
</button>
|
||||||
{(agents ?? [])
|
</PopoverTrigger>
|
||||||
.filter((agent) => {
|
<PopoverContent
|
||||||
if (!assigneeSearch.trim()) return true;
|
className="w-56 p-1"
|
||||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
align="end"
|
||||||
})
|
onClick={(e) => e.stopPropagation()}
|
||||||
.map((agent) => (
|
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||||
<button
|
>
|
||||||
key={agent.id}
|
<input
|
||||||
className={cn(
|
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"
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-left",
|
placeholder="Search agents..."
|
||||||
issue.assigneeAgentId === agent.id && "bg-accent"
|
value={assigneeSearch}
|
||||||
)}
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||||
onClick={(e) => {
|
autoFocus
|
||||||
e.preventDefault();
|
/>
|
||||||
e.stopPropagation();
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
assignIssue(issue.id, agent.id);
|
<button
|
||||||
}}
|
className={cn(
|
||||||
>
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
!issue.assigneeAgentId && "bg-accent",
|
||||||
</button>
|
)}
|
||||||
))}
|
onClick={(e) => {
|
||||||
</div>
|
e.preventDefault();
|
||||||
</PopoverContent>
|
e.stopPropagation();
|
||||||
</Popover>
|
assignIssue(issue.id, null);
|
||||||
<span className="text-xs text-muted-foreground">
|
}}
|
||||||
{formatDate(issue.createdAt)}
|
>
|
||||||
</span>
|
No assignee
|
||||||
</span>
|
</button>
|
||||||
</Link>
|
{(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>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|||||||
@@ -1034,8 +1034,6 @@ export function NewIssueDialog() {
|
|||||||
</span>
|
</span>
|
||||||
) : createIssue.isError ? (
|
) : createIssue.isError ? (
|
||||||
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
|
<span className="text-xs text-destructive">{createIssueErrorMessage}</span>
|
||||||
) : canDiscardDraft ? (
|
|
||||||
<span className="text-xs text-muted-foreground">Draft autosaves locally</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
84
ui/src/components/transcript/RunTranscriptView.test.tsx
Normal file
84
ui/src/components/transcript/RunTranscriptView.test.tsx
Normal 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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ interface RunTranscriptViewProps {
|
|||||||
collapseStdout?: boolean;
|
collapseStdout?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
thinkingClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TranscriptBlock =
|
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;
|
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 {
|
function humanizeLabel(value: string): string {
|
||||||
return value
|
return value
|
||||||
.replace(/[_-]+/g, " ")
|
.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[] {
|
function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
||||||
const grouped: TranscriptBlock[] = [];
|
const grouped: TranscriptBlock[] = [];
|
||||||
let pending: Array<Extract<TranscriptBlock, { type: "command_group" }>["items"][number]> = [];
|
let pending: Array<Extract<TranscriptBlock, { type: "command_group" }>["items"][number]> = [];
|
||||||
@@ -329,7 +325,7 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
|
|||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
|
||||||
const blocks: TranscriptBlock[] = [];
|
const blocks: TranscriptBlock[] = [];
|
||||||
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
|
||||||
const pendingActivityBlocks = new Map<string, Extract<TranscriptBlock, { type: "activity" }>>();
|
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 (entry.kind === "stderr") {
|
||||||
|
if (shouldHideNiceModeStderr(entry.text)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "event",
|
type: "event",
|
||||||
ts: entry.ts,
|
ts: entry.ts,
|
||||||
@@ -486,6 +485,17 @@ function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): Tr
|
|||||||
continue;
|
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") {
|
if (previous?.type === "stdout") {
|
||||||
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
|
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
|
||||||
previous.ts = entry.ts;
|
previous.ts = entry.ts;
|
||||||
@@ -519,15 +529,14 @@ function TranscriptMessageBlock({
|
|||||||
<span>User</span>
|
<span>User</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{compact ? (
|
<MarkdownBody
|
||||||
<div className="text-xs leading-5 text-foreground/85 whitespace-pre-wrap break-words">
|
className={cn(
|
||||||
{truncate(stripMarkdown(block.text), 360)}
|
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
</div>
|
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
|
||||||
) : (
|
)}
|
||||||
<MarkdownBody className="text-sm [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
>
|
||||||
{block.text}
|
{block.text}
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
)}
|
|
||||||
{block.streaming && (
|
{block.streaming && (
|
||||||
<div className="mt-2 inline-flex items-center gap-1 text-[10px] font-medium italic text-muted-foreground">
|
<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">
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
@@ -544,19 +553,22 @@ function TranscriptMessageBlock({
|
|||||||
function TranscriptThinkingBlock({
|
function TranscriptThinkingBlock({
|
||||||
block,
|
block,
|
||||||
density,
|
density,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
block: Extract<TranscriptBlock, { type: "thinking" }>;
|
block: Extract<TranscriptBlock, { type: "thinking" }>;
|
||||||
density: TranscriptDensity;
|
density: TranscriptDensity;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<MarkdownBody
|
||||||
className={cn(
|
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",
|
density === "compact" ? "text-[11px] leading-5" : "text-sm leading-6",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{block.text}
|
{block.text}
|
||||||
</div>
|
</MarkdownBody>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -956,6 +968,7 @@ export function RunTranscriptView({
|
|||||||
collapseStdout = false,
|
collapseStdout = false,
|
||||||
emptyMessage = "No transcript yet.",
|
emptyMessage = "No transcript yet.",
|
||||||
className,
|
className,
|
||||||
|
thinkingClassName,
|
||||||
}: RunTranscriptViewProps) {
|
}: RunTranscriptViewProps) {
|
||||||
const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]);
|
const blocks = useMemo(() => normalizeTranscript(entries, streaming), [entries, streaming]);
|
||||||
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
|
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")}
|
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 === "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 === "tool" && <TranscriptToolCard block={block} density={density} />}
|
||||||
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
|
||||||
{block.type === "stdout" && (
|
{block.type === "stdout" && (
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||||
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
import { RunTranscriptView, type TranscriptMode } from "../components/transcript/RunTranscriptView";
|
||||||
import { isUuidLike, type Agent, type HeartbeatRun, type HeartbeatRunEvent, type AgentRuntimeState, type LiveEvent } from "@paperclipai/shared";
|
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";
|
import { agentRouteRef } from "../lib/utils";
|
||||||
|
|
||||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
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 (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return redactHomePathUserSegments(value);
|
||||||
try {
|
try {
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(redactHomePathUserSegmentsInValue(value));
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return redactHomePathUserSegments(String(value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2023,7 +2024,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
|
|
||||||
const adapterInvokePayload = useMemo(() => {
|
const adapterInvokePayload = useMemo(() => {
|
||||||
const evt = events.find((e) => e.eventType === "adapter.invoke");
|
const evt = events.find((e) => e.eventType === "adapter.invoke");
|
||||||
return asRecord(evt?.payload ?? null);
|
return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null));
|
||||||
}, [events]);
|
}, [events]);
|
||||||
|
|
||||||
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
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>
|
<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">
|
<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"
|
{typeof adapterInvokePayload.prompt === "string"
|
||||||
? adapterInvokePayload.prompt
|
? redactHomePathUserSegments(adapterInvokePayload.prompt)
|
||||||
: JSON.stringify(adapterInvokePayload.prompt, null, 2)}
|
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2105,7 +2106,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-muted-foreground mb-1">Context</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">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2189,14 +2190,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
{run.error && (
|
{run.error && (
|
||||||
<div className="text-xs text-red-600 dark:text-red-200">
|
<div className="text-xs text-red-600 dark:text-red-200">
|
||||||
<span className="text-red-700 dark:text-red-300">Error: </span>
|
<span className="text-red-700 dark:text-red-300">Error: </span>
|
||||||
{run.error}
|
{redactHomePathUserSegments(run.error)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
|
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</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">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2204,7 +2205,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</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">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2212,7 +2213,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</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">
|
<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>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2238,7 +2239,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
{evt.stream ? `[${evt.stream}]` : ""}
|
{evt.stream ? `[${evt.stream}]` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn("break-all", color)}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import { useCompany } from "../context/CompanyContext";
|
|||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||||
import { StatusIcon } from "../components/StatusIcon";
|
|
||||||
import { PriorityIcon } from "../components/PriorityIcon";
|
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { ApprovalCard } from "../components/ApprovalCard";
|
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 { StatusBadge } from "../components/StatusBadge";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -350,6 +351,15 @@ export function Inbox() {
|
|||||||
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
() => getLatestFailedRunsByAgent(heartbeatRuns ?? []).filter((r) => !dismissed.has(`run:${r.id}`)),
|
||||||
[heartbeatRuns, dismissed],
|
[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(
|
const allApprovals = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -546,36 +556,37 @@ export function Inbox() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm: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-wrap items-center gap-2">
|
<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 && (
|
{canMarkAllRead && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8"
|
className="h-8 shrink-0"
|
||||||
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
onClick={() => markAllReadMutation.mutate(unreadIssueIds)}
|
||||||
disabled={markAllReadMutation.isPending}
|
disabled={markAllReadMutation.isPending}
|
||||||
>
|
>
|
||||||
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
{markAllReadMutation.isPending ? "Marking…" : "Mark all as read"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{tab === "all" && (
|
{tab === "all" && (
|
||||||
<>
|
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||||
<Select
|
<Select
|
||||||
value={allCategoryFilter}
|
value={allCategoryFilter}
|
||||||
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
|
||||||
@@ -608,9 +619,8 @@ export function Inbox() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
|
||||||
@@ -800,64 +810,52 @@ export function Inbox() {
|
|||||||
<>
|
<>
|
||||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||||
<div>
|
<div>
|
||||||
<div className="divide-y divide-border border border-border">
|
<div>
|
||||||
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
|
{(tab === "unread" ? unreadTouchedIssues : touchedIssues).map((issue) => {
|
||||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||||
const isFading = fadingOutIssues.has(issue.id);
|
const isFading = fadingOutIssues.has(issue.id);
|
||||||
return (
|
return (
|
||||||
<Link
|
<IssueRow
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
issue={issue}
|
||||||
state={issueLinkState}
|
issueLinkState={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"
|
desktopMetaLeading={(
|
||||||
>
|
<>
|
||||||
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center self-center">
|
<span className="hidden sm:inline-flex">
|
||||||
{(isUnread || isFading) ? (
|
<PriorityIcon priority={issue.priority} />
|
||||||
<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"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
<span className="inline-flex h-4 w-4" aria-hidden="true" />
|
<StatusIcon status={issue.status} />
|
||||||
)}
|
</span>
|
||||||
</span>
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
<span className="inline-flex shrink-0 self-center"><PriorityIcon priority={issue.priority} /></span>
|
</span>
|
||||||
<span className="inline-flex shrink-0 self-center"><StatusIcon status={issue.status} /></span>
|
{liveIssueIds.has(issue.id) && (
|
||||||
<span className="shrink-0 self-center text-xs font-mono text-muted-foreground">
|
<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">
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
<span className="relative flex h-2 w-2">
|
||||||
</span>
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||||
<span className="min-w-0 flex-1 text-sm">
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
||||||
<span className="line-clamp-2 min-w-0 sm:line-clamp-1 sm:block sm:truncate">
|
</span>
|
||||||
{issue.title}
|
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
||||||
</span>
|
Live
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden shrink-0 self-center text-xs text-muted-foreground sm:block">
|
</span>
|
||||||
{issue.lastExternalCommentAt
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
mobileMeta={
|
||||||
|
issue.lastExternalCommentAt
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
: `updated ${timeAgo(issue.updatedAt)}`}
|
: `updated ${timeAgo(issue.updatedAt)}`
|
||||||
</span>
|
}
|
||||||
</Link>
|
unreadState={isUnread ? "visible" : isFading ? "fading" : "hidden"}
|
||||||
|
onMarkRead={() => markReadMutation.mutate(issue.id)}
|
||||||
|
trailingMeta={
|
||||||
|
issue.lastExternalCommentAt
|
||||||
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
: `updated ${timeAgo(issue.updatedAt)}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user