mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
Merge public/master into pap-1239-server-test-isolation
This commit is contained in:
commit
da251e5eab
114 changed files with 22931 additions and 2057 deletions
209
.agents/skills/prcheckloop/SKILL.md
Normal file
209
.agents/skills/prcheckloop/SKILL.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
---
|
||||||
|
name: prcheckloop
|
||||||
|
description: >
|
||||||
|
Iteratively gets a GitHub pull request's checks green. Detects the PR for the
|
||||||
|
current branch or uses a provided PR number, waits for every check on the
|
||||||
|
latest head SHA to appear and finish, investigates failing checks, fixes
|
||||||
|
actionable code or test issues, pushes, and repeats. Escalates with a precise
|
||||||
|
blocker when failures are external, flaky, or not safely fixable. Use when a
|
||||||
|
PR still has unsuccessful checks after review fixes, including after greploop.
|
||||||
|
---
|
||||||
|
|
||||||
|
# PRCheckloop
|
||||||
|
|
||||||
|
Get a GitHub PR to a fully green check state, or exit with a concrete blocker.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- GitHub PRs only. If the repo is GitLab, stop and use `check-pr`.
|
||||||
|
- Focus on checks for the latest PR head SHA, not old commits.
|
||||||
|
- Focus on CI/status checks, not review comments or PR template cleanup.
|
||||||
|
- If the user also wants review-comment cleanup, pair this with `check-pr`.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- **PR number** (optional): If not provided, detect the PR for the current branch.
|
||||||
|
- **Max iterations**: default `5`.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Identify the PR
|
||||||
|
|
||||||
|
If no PR number is provided, detect it from the current branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh pr view --json number,headRefName,headRefOid,url,isDraft
|
||||||
|
```
|
||||||
|
|
||||||
|
If needed, switch to the PR branch before making changes.
|
||||||
|
|
||||||
|
Stop early if:
|
||||||
|
|
||||||
|
- `gh` is not authenticated
|
||||||
|
- there is no PR for the branch
|
||||||
|
- the repo is not hosted on GitHub
|
||||||
|
|
||||||
|
### 2. Track the latest head SHA
|
||||||
|
|
||||||
|
Always work against the current PR head SHA:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PR_JSON=$(gh pr view "$PR_NUMBER" --json number,headRefName,headRefOid,url)
|
||||||
|
HEAD_SHA=$(echo "$PR_JSON" | jq -r .headRefOid)
|
||||||
|
PR_URL=$(echo "$PR_JSON" | jq -r .url)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ignore failing checks from older SHAs. After every push, refresh `HEAD_SHA` and
|
||||||
|
restart the inspection loop.
|
||||||
|
|
||||||
|
### 3. Inventory checks for that SHA
|
||||||
|
|
||||||
|
Fetch both GitHub check runs and legacy commit status contexts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/check-runs?per_page=100"
|
||||||
|
gh api "repos/{owner}/{repo}/commits/$HEAD_SHA/status"
|
||||||
|
```
|
||||||
|
|
||||||
|
For a compact PR-level view, this GraphQL payload is useful:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh api graphql -f query='
|
||||||
|
query($owner:String!, $repo:String!, $pr:Int!) {
|
||||||
|
repository(owner:$owner, name:$repo) {
|
||||||
|
pullRequest(number:$pr) {
|
||||||
|
headRefOid
|
||||||
|
url
|
||||||
|
statusCheckRollup {
|
||||||
|
contexts(first:100) {
|
||||||
|
nodes {
|
||||||
|
__typename
|
||||||
|
... on CheckRun { name status conclusion detailsUrl workflowName }
|
||||||
|
... on StatusContext { context state targetUrl description }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' -F owner=OWNER -F repo=REPO -F pr="$PR_NUMBER"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Wait for checks to actually run
|
||||||
|
|
||||||
|
After a new push, checks can take a moment to appear. Poll every 15-30 seconds
|
||||||
|
until one of these is true:
|
||||||
|
|
||||||
|
- checks have appeared and every item is in a terminal state
|
||||||
|
- checks have appeared and at least one failed
|
||||||
|
- no checks appear after a reasonable wait, usually 2 minutes
|
||||||
|
|
||||||
|
Treat these as terminal success states:
|
||||||
|
|
||||||
|
- check runs: `SUCCESS`, `NEUTRAL`, `SKIPPED`
|
||||||
|
- status contexts: `SUCCESS`
|
||||||
|
|
||||||
|
Treat these as pending:
|
||||||
|
|
||||||
|
- check runs: `QUEUED`, `PENDING`, `WAITING`, `REQUESTED`, `IN_PROGRESS`
|
||||||
|
- status contexts: `PENDING`
|
||||||
|
|
||||||
|
Treat these as failures:
|
||||||
|
|
||||||
|
- check runs: `FAILURE`, `TIMED_OUT`, `CANCELLED`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`
|
||||||
|
- status contexts: `FAILURE`, `ERROR`
|
||||||
|
|
||||||
|
If no checks appear for the latest SHA, inspect `.github/workflows/`, workflow
|
||||||
|
path filters, and branch protection expectations. If the missing check cannot be
|
||||||
|
caused or fixed from the repo, escalate.
|
||||||
|
|
||||||
|
### 5. Investigate failing checks
|
||||||
|
|
||||||
|
For GitHub Actions failures, inspect runs and failed logs for the current SHA:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh run list --commit "$HEAD_SHA" --json databaseId,workflowName,status,conclusion,url,headSha
|
||||||
|
gh run view <RUN_ID> --json databaseId,name,workflowName,status,conclusion,jobs,url,headSha
|
||||||
|
gh run view <RUN_ID> --log-failed
|
||||||
|
```
|
||||||
|
|
||||||
|
For each failing check, classify it:
|
||||||
|
|
||||||
|
| Failure type | Action |
|
||||||
|
|---|---|
|
||||||
|
| Code/test regression | Reproduce locally, fix, and verify |
|
||||||
|
| Lint/type/build mismatch | Run the matching local command from the workflow and fix it |
|
||||||
|
| Flake or transient infra issue | Rerun once if evidence supports flakiness |
|
||||||
|
| External service/status app failure | Escalate with the details URL and owner guess |
|
||||||
|
| Missing secret/permission/branch protection issue | Escalate immediately |
|
||||||
|
|
||||||
|
Only rerun a failed job once without code changes. Do not loop on reruns.
|
||||||
|
|
||||||
|
### 6. Fix actionable failures
|
||||||
|
|
||||||
|
If the failure is actionable from the checked-out code:
|
||||||
|
|
||||||
|
1. Read the workflow or failing command to identify the real gate.
|
||||||
|
2. Reproduce locally where reasonable.
|
||||||
|
3. Make the smallest correct fix.
|
||||||
|
4. Run focused verification first, then broader verification if needed.
|
||||||
|
5. Commit in a logical commit.
|
||||||
|
6. Push before re-checking the PR.
|
||||||
|
|
||||||
|
Do not stop at a local fix. The loop is only complete when the remote PR checks
|
||||||
|
for the new head SHA are green.
|
||||||
|
|
||||||
|
### 7. Push and repeat
|
||||||
|
|
||||||
|
After each fix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
sleep 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Then refresh the PR metadata, get the new `HEAD_SHA`, and restart from Step 3.
|
||||||
|
|
||||||
|
Exit the loop only when:
|
||||||
|
|
||||||
|
- all checks for the latest head SHA are green, or
|
||||||
|
- a blocker remains after reasonable repair effort, or
|
||||||
|
- the max iteration count is reached
|
||||||
|
|
||||||
|
### 8. Escalate blockers precisely
|
||||||
|
|
||||||
|
If you cannot get the PR green, report:
|
||||||
|
|
||||||
|
- PR URL
|
||||||
|
- latest head SHA
|
||||||
|
- exact failing or missing check names
|
||||||
|
- details URLs
|
||||||
|
- what you already tried
|
||||||
|
- why it is blocked
|
||||||
|
- who should likely unblock it
|
||||||
|
- the next concrete action
|
||||||
|
|
||||||
|
Good blocker examples:
|
||||||
|
|
||||||
|
- external status app outage
|
||||||
|
- missing GitHub secret or permission
|
||||||
|
- required check name mismatch in branch protection
|
||||||
|
- persistent flake after one rerun
|
||||||
|
- failure needs credentials or infrastructure access you do not have
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
When the skill completes, report:
|
||||||
|
|
||||||
|
- PR URL and branch
|
||||||
|
- final head SHA
|
||||||
|
- green/pending/failing check summary
|
||||||
|
- fixes made and verification run
|
||||||
|
- whether changes were pushed
|
||||||
|
- blocker summary if not fully green
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This skill is intentionally narrower than `check-pr`: it is a repair loop for
|
||||||
|
PR checks.
|
||||||
|
- This skill complements `greploop`: Greptile can be perfect while CI is still
|
||||||
|
red.
|
||||||
|
|
@ -81,8 +81,8 @@ If you change schema/API behavior, update all impacted layers:
|
||||||
4. Do not replace strategic docs wholesale unless asked.
|
4. Do not replace strategic docs wholesale unless asked.
|
||||||
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
|
Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned.
|
||||||
|
|
||||||
5. Keep plan docs dated and centralized.
|
5. Keep repo plan docs dated and centralized.
|
||||||
New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames.
|
When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file.
|
||||||
|
|
||||||
## 6. Database Change Workflow
|
## 6. Database Change Workflow
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import {
|
||||||
readSourceAttachmentBody,
|
readSourceAttachmentBody,
|
||||||
rebindWorkspaceCwd,
|
rebindWorkspaceCwd,
|
||||||
resolveSourceConfigPath,
|
resolveSourceConfigPath,
|
||||||
|
resolveWorktreeReseedSource,
|
||||||
|
resolveWorktreeReseedTargetPaths,
|
||||||
resolveGitWorktreeAddArgs,
|
resolveGitWorktreeAddArgs,
|
||||||
resolveWorktreeMakeTargetPath,
|
resolveWorktreeMakeTargetPath,
|
||||||
worktreeInitCommand,
|
worktreeInitCommand,
|
||||||
|
|
@ -482,27 +484,69 @@ describe("worktree helpers", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires an explicit source for worktree reseed", async () => {
|
it("requires an explicit reseed source", () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-source-"));
|
expect(() => resolveWorktreeReseedSource({})).toThrow(
|
||||||
const repoRoot = path.join(tempRoot, "repo");
|
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
|
||||||
const originalCwd = process.cwd();
|
);
|
||||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
});
|
||||||
|
|
||||||
|
it("rejects mixed reseed source selectors", () => {
|
||||||
|
expect(() => resolveWorktreeReseedSource({
|
||||||
|
from: "current",
|
||||||
|
fromInstance: "default",
|
||||||
|
})).toThrow(
|
||||||
|
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives worktree reseed target paths from the adjacent env file", () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-"));
|
||||||
|
const worktreeRoot = path.join(tempRoot, "repo");
|
||||||
|
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
||||||
|
const envPath = path.join(worktreeRoot, ".paperclip", ".env");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(repoRoot, { recursive: true });
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
delete process.env.PAPERCLIP_CONFIG;
|
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||||
process.chdir(repoRoot);
|
fs.writeFileSync(
|
||||||
|
envPath,
|
||||||
await expect(worktreeReseedCommand({ seed: false, yes: true })).rejects.toThrow(
|
[
|
||||||
"Reseed requires an explicit source.",
|
"PAPERCLIP_HOME=/tmp/paperclip-worktrees",
|
||||||
|
"PAPERCLIP_INSTANCE_ID=pap-1132-chat",
|
||||||
|
].join("\n"),
|
||||||
|
"utf8",
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
resolveWorktreeReseedTargetPaths({
|
||||||
|
configPath,
|
||||||
|
rootPath: worktreeRoot,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
cwd: worktreeRoot,
|
||||||
|
homeDir: "/tmp/paperclip-worktrees",
|
||||||
|
instanceId: "pap-1132-chat",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects reseed targets without worktree env metadata", () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-target-missing-"));
|
||||||
|
const worktreeRoot = path.join(tempRoot, "repo");
|
||||||
|
const configPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify(buildSourceConfig()), "utf8");
|
||||||
|
fs.writeFileSync(path.join(worktreeRoot, ".paperclip", ".env"), "", "utf8");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
resolveWorktreeReseedTargetPaths({
|
||||||
|
configPath,
|
||||||
|
rootPath: worktreeRoot,
|
||||||
|
})).toThrow("does not look like a worktree-local Paperclip instance");
|
||||||
} finally {
|
} finally {
|
||||||
process.chdir(originalCwd);
|
|
||||||
if (originalPaperclipConfig === undefined) {
|
|
||||||
delete process.env.PAPERCLIP_CONFIG;
|
|
||||||
} else {
|
|
||||||
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
|
|
||||||
}
|
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -529,6 +573,7 @@ describe("worktree helpers", () => {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
|
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
|
||||||
fs.mkdirSync(repoRoot, { recursive: true });
|
fs.mkdirSync(repoRoot, { recursive: true });
|
||||||
fs.mkdirSync(sourceRoot, { recursive: true });
|
fs.mkdirSync(sourceRoot, { recursive: true });
|
||||||
|
|
||||||
|
|
@ -546,6 +591,7 @@ describe("worktree helpers", () => {
|
||||||
});
|
});
|
||||||
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
||||||
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
||||||
|
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
currentPaths.envPath,
|
currentPaths.envPath,
|
||||||
[
|
[
|
||||||
|
|
@ -562,7 +608,6 @@ describe("worktree helpers", () => {
|
||||||
|
|
||||||
await worktreeReseedCommand({
|
await worktreeReseedCommand({
|
||||||
fromConfig: sourcePaths.configPath,
|
fromConfig: sourcePaths.configPath,
|
||||||
seed: false,
|
|
||||||
yes: true,
|
yes: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -584,7 +629,7 @@ describe("worktree helpers", () => {
|
||||||
}
|
}
|
||||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
}, 20_000);
|
||||||
|
|
||||||
it("restores the current worktree config and instance data if reseed fails", async () => {
|
it("restores the current worktree config and instance data if reseed fails", async () => {
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
|
||||||
|
|
|
||||||
|
|
@ -98,22 +98,6 @@ type WorktreeMakeOptions = WorktreeInitOptions & {
|
||||||
startPoint?: string;
|
startPoint?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorktreeReseedOptions = {
|
|
||||||
fromConfig?: string;
|
|
||||||
fromDataDir?: string;
|
|
||||||
fromInstance?: string;
|
|
||||||
home?: string;
|
|
||||||
seedMode?: string;
|
|
||||||
yes?: boolean;
|
|
||||||
seed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorktreeReseedBackup = {
|
|
||||||
tempRoot: string;
|
|
||||||
repoConfigDirBackup: string | null;
|
|
||||||
instanceRootBackup: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorktreeEnvOptions = {
|
type WorktreeEnvOptions = {
|
||||||
config?: string;
|
config?: string;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
|
|
@ -133,6 +117,17 @@ type WorktreeMergeHistoryOptions = {
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WorktreeReseedOptions = {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
fromConfig?: string;
|
||||||
|
fromDataDir?: string;
|
||||||
|
fromInstance?: string;
|
||||||
|
seedMode?: string;
|
||||||
|
yes?: boolean;
|
||||||
|
allowLiveTarget?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type EmbeddedPostgresInstance = {
|
type EmbeddedPostgresInstance = {
|
||||||
initialise(): Promise<void>;
|
initialise(): Promise<void>;
|
||||||
start(): Promise<void>;
|
start(): Promise<void>;
|
||||||
|
|
@ -738,6 +733,65 @@ export function resolveSourceConfigPath(opts: WorktreeInitOptions): string {
|
||||||
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): ResolvedWorktreeReseedSource {
|
||||||
|
const fromSelector = nonEmpty(input.from);
|
||||||
|
const fromConfig = nonEmpty(input.fromConfig);
|
||||||
|
const fromDataDir = nonEmpty(input.fromDataDir);
|
||||||
|
const fromInstance = nonEmpty(input.fromInstance);
|
||||||
|
const hasExplicitConfigSource = Boolean(fromConfig || fromDataDir || fromInstance);
|
||||||
|
|
||||||
|
if (fromSelector && hasExplicitConfigSource) {
|
||||||
|
throw new Error(
|
||||||
|
"Use either --from <worktree> or --from-config/--from-data-dir/--from-instance, not both.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromSelector) {
|
||||||
|
const endpoint = resolveWorktreeEndpointFromSelector(fromSelector, { allowCurrent: true });
|
||||||
|
return {
|
||||||
|
configPath: endpoint.configPath,
|
||||||
|
label: endpoint.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExplicitConfigSource) {
|
||||||
|
const configPath = resolveSourceConfigPath({
|
||||||
|
fromConfig: fromConfig ?? undefined,
|
||||||
|
fromDataDir: fromDataDir ?? undefined,
|
||||||
|
fromInstance: fromInstance ?? undefined,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
configPath,
|
||||||
|
label: configPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"Pass --from <worktree> or --from-config/--from-instance explicitly so the reseed source is unambiguous.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorktreeReseedTargetPaths(input: {
|
||||||
|
configPath: string;
|
||||||
|
rootPath: string;
|
||||||
|
}): WorktreeLocalPaths {
|
||||||
|
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(input.configPath));
|
||||||
|
const homeDir = nonEmpty(envEntries.PAPERCLIP_HOME);
|
||||||
|
const instanceId = nonEmpty(envEntries.PAPERCLIP_INSTANCE_ID);
|
||||||
|
|
||||||
|
if (!homeDir || !instanceId) {
|
||||||
|
throw new Error(
|
||||||
|
`Target config ${input.configPath} does not look like a worktree-local Paperclip instance. Expected PAPERCLIP_HOME and PAPERCLIP_INSTANCE_ID in the adjacent .env.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveWorktreeLocalPaths({
|
||||||
|
cwd: input.rootPath,
|
||||||
|
homeDir,
|
||||||
|
instanceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||||
if (config.database.mode === "postgres") {
|
if (config.database.mode === "postgres") {
|
||||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||||
|
|
@ -894,6 +948,8 @@ async function seedWorktreeDatabase(input: {
|
||||||
input.sourceConfig.database.embeddedPostgresDataDir,
|
input.sourceConfig.database.embeddedPostgresDataDir,
|
||||||
input.sourceConfig.database.embeddedPostgresPort,
|
input.sourceConfig.database.embeddedPostgresPort,
|
||||||
);
|
);
|
||||||
|
const sourceAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${sourceHandle.port}/postgres`;
|
||||||
|
await ensurePostgresDatabase(sourceAdminConnectionString, "paperclip");
|
||||||
}
|
}
|
||||||
const sourceConnectionString = resolveSourceConnectionString(
|
const sourceConnectionString = resolveSourceConnectionString(
|
||||||
input.sourceConfig,
|
input.sourceConfig,
|
||||||
|
|
@ -1068,160 +1124,6 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
|
||||||
await runWorktreeInit(opts);
|
await runWorktreeInit(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasExplicitSourceSelection(opts: {
|
|
||||||
fromConfig?: string;
|
|
||||||
fromDataDir?: string;
|
|
||||||
fromInstance?: string;
|
|
||||||
sourceConfigPathOverride?: string;
|
|
||||||
}): boolean {
|
|
||||||
return Boolean(
|
|
||||||
nonEmpty(opts.fromConfig)
|
|
||||||
|| nonEmpty(opts.fromDataDir)
|
|
||||||
|| nonEmpty(opts.fromInstance)
|
|
||||||
|| nonEmpty(opts.sourceConfigPathOverride),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCurrentWorktreeReseedState(opts: { home?: string } = {}) {
|
|
||||||
const currentConfigPath = resolveConfigPath();
|
|
||||||
if (!existsSync(currentConfigPath)) {
|
|
||||||
throw new Error(
|
|
||||||
"Current directory does not have a Paperclip worktree config. Run `paperclipai worktree init` here first.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const currentConfig = readConfig(currentConfigPath);
|
|
||||||
if (!currentConfig) {
|
|
||||||
throw new Error(`Could not read current worktree config at ${currentConfigPath}.`);
|
|
||||||
}
|
|
||||||
if (currentConfig.database.mode !== "embedded-postgres") {
|
|
||||||
throw new Error("Worktree reseed only supports embedded-postgres worktree instances.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(currentConfigPath));
|
|
||||||
const instanceRoot = path.dirname(currentConfig.database.embeddedPostgresDataDir);
|
|
||||||
const derivedHomeDir = path.dirname(path.dirname(instanceRoot));
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentConfigPath: path.resolve(currentConfigPath),
|
|
||||||
instanceId:
|
|
||||||
nonEmpty(currentEnvEntries.PAPERCLIP_INSTANCE_ID)
|
|
||||||
?? nonEmpty(path.basename(instanceRoot))
|
|
||||||
?? sanitizeWorktreeInstanceId(path.basename(process.cwd())),
|
|
||||||
homeDir: path.resolve(expandHomePrefix(opts.home ?? currentEnvEntries.PAPERCLIP_HOME ?? derivedHomeDir)),
|
|
||||||
serverPort: currentConfig.server.port,
|
|
||||||
dbPort: currentConfig.database.embeddedPostgresPort,
|
|
||||||
worktreeName: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_NAME) ?? undefined,
|
|
||||||
worktreeColor: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_COLOR) ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshotDirectory(sourcePath: string, targetPath: string): Promise<string | null> {
|
|
||||||
if (!existsSync(sourcePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
await fsPromises.cp(sourcePath, targetPath, { recursive: true });
|
|
||||||
return targetPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function snapshotWorktreeReseedState(target: {
|
|
||||||
repoConfigDir: string;
|
|
||||||
instanceRoot: string;
|
|
||||||
}): Promise<WorktreeReseedBackup> {
|
|
||||||
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-reseed-backup-"));
|
|
||||||
return {
|
|
||||||
tempRoot,
|
|
||||||
repoConfigDirBackup: await snapshotDirectory(
|
|
||||||
target.repoConfigDir,
|
|
||||||
path.resolve(tempRoot, "repo-config"),
|
|
||||||
),
|
|
||||||
instanceRootBackup: await snapshotDirectory(
|
|
||||||
target.instanceRoot,
|
|
||||||
path.resolve(tempRoot, "instance-root"),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreDirectoryBackup(backupPath: string | null, targetPath: string): Promise<void> {
|
|
||||||
rmSync(targetPath, { recursive: true, force: true });
|
|
||||||
if (!backupPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fsPromises.cp(backupPath, targetPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreWorktreeReseedState(
|
|
||||||
backup: WorktreeReseedBackup,
|
|
||||||
target: { repoConfigDir: string; instanceRoot: string },
|
|
||||||
): Promise<void> {
|
|
||||||
await restoreDirectoryBackup(backup.repoConfigDirBackup, target.repoConfigDir);
|
|
||||||
await restoreDirectoryBackup(backup.instanceRootBackup, target.instanceRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
|
||||||
printPaperclipCliBanner();
|
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
|
||||||
|
|
||||||
if (!hasExplicitSourceSelection(opts)) {
|
|
||||||
throw new Error(
|
|
||||||
"Reseed requires an explicit source. Pass --from-config or --from-instance (optionally with --from-data-dir).",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = resolveCurrentWorktreeReseedState({ home: opts.home });
|
|
||||||
const sourceConfigPath = resolveSourceConfigPath(opts);
|
|
||||||
if (path.resolve(sourceConfigPath) === target.currentConfigPath) {
|
|
||||||
throw new Error(
|
|
||||||
"Source and target Paperclip configs are the same. Pass a different source instance/config when reseeding.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const seedMode = opts.seedMode ?? "minimal";
|
|
||||||
if (!isWorktreeSeedMode(seedMode)) {
|
|
||||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = opts.yes
|
|
||||||
? true
|
|
||||||
: await p.confirm({
|
|
||||||
message: `Reseed the current worktree instance (${target.instanceId}) from ${sourceConfigPath}? This overwrites only the current worktree Paperclip instance data.`,
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
if (p.isCancel(confirmed) || !confirmed) {
|
|
||||||
p.log.warn("Reseed cancelled.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetPaths = resolveWorktreeLocalPaths({
|
|
||||||
cwd: process.cwd(),
|
|
||||||
homeDir: target.homeDir,
|
|
||||||
instanceId: target.instanceId,
|
|
||||||
});
|
|
||||||
const backup = await snapshotWorktreeReseedState(targetPaths);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runWorktreeInit({
|
|
||||||
name: target.worktreeName,
|
|
||||||
color: target.worktreeColor,
|
|
||||||
instance: target.instanceId,
|
|
||||||
home: target.homeDir,
|
|
||||||
fromConfig: opts.fromConfig,
|
|
||||||
fromDataDir: opts.fromDataDir,
|
|
||||||
fromInstance: opts.fromInstance,
|
|
||||||
sourceConfigPathOverride: sourceConfigPath,
|
|
||||||
serverPort: target.serverPort,
|
|
||||||
dbPort: target.dbPort,
|
|
||||||
seed: opts.seed ?? true,
|
|
||||||
seedMode,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
await restoreWorktreeReseedState(backup, targetPaths);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
rmSync(backup.tempRoot, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
|
||||||
printPaperclipCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
|
||||||
|
|
@ -1326,6 +1228,11 @@ type ResolvedWorktreeEndpoint = {
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ResolvedWorktreeReseedSource = {
|
||||||
|
configPath: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
||||||
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
|
|
@ -1819,6 +1726,13 @@ function renderMergePlan(plan: Awaited<ReturnType<typeof collectMergePlan>>["pla
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRunningEmbeddedPostgresPid(config: PaperclipConfig): number | null {
|
||||||
|
if (config.database.mode !== "embedded-postgres") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return readRunningPostmasterPid(path.resolve(config.database.embeddedPostgresDataDir, "postmaster.pid"));
|
||||||
|
}
|
||||||
|
|
||||||
async function collectMergePlan(input: {
|
async function collectMergePlan(input: {
|
||||||
sourceDb: ClosableDb;
|
sourceDb: ClosableDb;
|
||||||
targetDb: ClosableDb;
|
targetDb: ClosableDb;
|
||||||
|
|
@ -2760,6 +2674,89 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||||
|
printPaperclipCliBanner();
|
||||||
|
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||||
|
|
||||||
|
const seedMode = opts.seedMode ?? "full";
|
||||||
|
if (!isWorktreeSeedMode(seedMode)) {
|
||||||
|
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetEndpoint = opts.to
|
||||||
|
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
|
||||||
|
: resolveCurrentEndpoint();
|
||||||
|
const source = resolveWorktreeReseedSource(opts);
|
||||||
|
|
||||||
|
if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) {
|
||||||
|
throw new Error("Source and target Paperclip configs are the same. Choose different --from/--to values.");
|
||||||
|
}
|
||||||
|
if (!existsSync(source.configPath)) {
|
||||||
|
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetConfig = readConfig(targetEndpoint.configPath);
|
||||||
|
if (!targetConfig) {
|
||||||
|
throw new Error(`Target config not found at ${targetEndpoint.configPath}.`);
|
||||||
|
}
|
||||||
|
const sourceConfig = readConfig(source.configPath);
|
||||||
|
if (!sourceConfig) {
|
||||||
|
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPaths = resolveWorktreeReseedTargetPaths({
|
||||||
|
configPath: targetEndpoint.configPath,
|
||||||
|
rootPath: targetEndpoint.rootPath,
|
||||||
|
});
|
||||||
|
const runningTargetPid = resolveRunningEmbeddedPostgresPid(targetConfig);
|
||||||
|
if (runningTargetPid && !opts.allowLiveTarget) {
|
||||||
|
throw new Error(
|
||||||
|
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${targetEndpoint.rootPath} before reseeding, or re-run with --allow-live-target if you want to override this guard.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = opts.yes
|
||||||
|
? true
|
||||||
|
: await p.confirm({
|
||||||
|
message: `Overwrite the isolated Paperclip DB for ${targetEndpoint.label} from ${source.label} using ${seedMode} seed mode?`,
|
||||||
|
initialValue: false,
|
||||||
|
});
|
||||||
|
if (p.isCancel(confirmed) || !confirmed) {
|
||||||
|
p.log.warn("Reseed cancelled.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runningTargetPid && opts.allowLiveTarget) {
|
||||||
|
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spinner = p.spinner();
|
||||||
|
spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`);
|
||||||
|
try {
|
||||||
|
const seeded = await seedWorktreeDatabase({
|
||||||
|
sourceConfigPath: source.configPath,
|
||||||
|
sourceConfig,
|
||||||
|
targetConfig,
|
||||||
|
targetPaths,
|
||||||
|
instanceId: targetPaths.instanceId,
|
||||||
|
seedMode,
|
||||||
|
});
|
||||||
|
spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`);
|
||||||
|
p.log.message(pc.dim(`Source: ${source.configPath}`));
|
||||||
|
p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`));
|
||||||
|
p.log.message(pc.dim(`Seed snapshot: ${seeded.backupSummary}`));
|
||||||
|
for (const rebound of seeded.reboundWorkspaces) {
|
||||||
|
p.log.message(
|
||||||
|
pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
p.outro(pc.green(`Reseed complete for ${targetEndpoint.label}.`));
|
||||||
|
} catch (error) {
|
||||||
|
spinner.stop(pc.red("Failed to reseed worktree database."));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerWorktreeCommands(program: Command): void {
|
export function registerWorktreeCommands(program: Command): void {
|
||||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||||
|
|
||||||
|
|
@ -2803,17 +2800,6 @@ export function registerWorktreeCommands(program: Command): void {
|
||||||
.option("--json", "Print JSON instead of shell exports")
|
.option("--json", "Print JSON instead of shell exports")
|
||||||
.action(worktreeEnvCommand);
|
.action(worktreeEnvCommand);
|
||||||
|
|
||||||
worktree
|
|
||||||
.command("reseed")
|
|
||||||
.description("Replace the current worktree instance with a fresh seed while preserving this worktree's ports and instance id")
|
|
||||||
.option("--from-config <path>", "Source config.json to seed from")
|
|
||||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
|
||||||
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
|
||||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
|
||||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
|
||||||
.option("--yes", "Skip the destructive confirmation prompt", false)
|
|
||||||
.action(worktreeReseedCommand);
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("worktree:list")
|
.command("worktree:list")
|
||||||
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")
|
||||||
|
|
@ -2833,6 +2819,19 @@ export function registerWorktreeCommands(program: Command): void {
|
||||||
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
|
.option("--yes", "Skip the interactive confirmation prompt when applying", false)
|
||||||
.action(worktreeMergeHistoryCommand);
|
.action(worktreeMergeHistoryCommand);
|
||||||
|
|
||||||
|
worktree
|
||||||
|
.command("reseed")
|
||||||
|
.description("Re-seed an existing worktree-local instance from another Paperclip instance or worktree")
|
||||||
|
.option("--from <worktree>", "Source worktree path, directory name, branch name, or current")
|
||||||
|
.option("--to <worktree>", "Target worktree path, directory name, branch name, or current (defaults to current)")
|
||||||
|
.option("--from-config <path>", "Source config.json to seed from")
|
||||||
|
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||||
|
.option("--from-instance <id>", "Source instance id when deriving the source config")
|
||||||
|
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: full)", "full")
|
||||||
|
.option("--yes", "Skip the destructive confirmation prompt", false)
|
||||||
|
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||||
|
.action(worktreeReseedCommand);
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("worktree:cleanup")
|
.command("worktree:cleanup")
|
||||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||||
|
|
|
||||||
|
|
@ -232,14 +232,38 @@ pnpm paperclipai worktree init --force --seed-mode minimal \
|
||||||
|
|
||||||
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
|
||||||
|
|
||||||
For existing worktrees, prefer the dedicated reseed command instead of rebuilding the `worktree init --force` flags manually:
|
For an already-created worktree where you want to keep the existing repo-local config/env and only overwrite the isolated database, use `worktree reseed` instead. Stop the target worktree's Paperclip server first so the command can replace the DB safely.
|
||||||
|
|
||||||
|
**`pnpm paperclipai worktree reseed [options]`** — Re-seed an existing worktree-local instance from another Paperclip instance or worktree while preserving the target worktree's current config, ports, and instance identity.
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|---|---|
|
||||||
|
| `--from <worktree>` | Source worktree path, directory name, branch name, or `current` |
|
||||||
|
| `--to <worktree>` | Target worktree path, directory name, branch name, or `current` (defaults to `current`) |
|
||||||
|
| `--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 when deriving the source config |
|
||||||
|
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `full`) |
|
||||||
|
| `--yes` | Skip the destructive confirmation prompt |
|
||||||
|
| `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first |
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd /path/to/existing/worktree
|
# From the main repo, reseed a worktree from the current default/master instance.
|
||||||
pnpm paperclipai worktree reseed --from-config /path/to/source/.paperclip/config.json --seed-mode full
|
cd /path/to/paperclip
|
||||||
```
|
pnpm paperclipai worktree reseed \
|
||||||
|
--from current \
|
||||||
|
--to PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat \
|
||||||
|
--seed-mode full \
|
||||||
|
--yes
|
||||||
|
|
||||||
`worktree reseed` preserves the current worktree's instance id, ports, and branding while replacing only that worktree's isolated Paperclip instance data from the chosen source.
|
# From inside a worktree, reseed it from the default instance config.
|
||||||
|
cd /path/to/paperclip/.paperclip/worktrees/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat
|
||||||
|
pnpm paperclipai worktree reseed \
|
||||||
|
--from-instance default \
|
||||||
|
--seed-mode full
|
||||||
|
```
|
||||||
|
|
||||||
**`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.
|
**`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.
|
||||||
|
|
||||||
|
|
@ -267,17 +291,6 @@ pnpm paperclipai worktree:make experiment --no-seed
|
||||||
|
|
||||||
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
|
||||||
|
|
||||||
**`pnpm paperclipai worktree reseed [options]`** — Replace the current worktree instance with a fresh seed from another Paperclip source while preserving the current worktree's ports and instance id.
|
|
||||||
|
|
||||||
| Option | Description |
|
|
||||||
|---|---|
|
|
||||||
| `--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 when deriving the source config |
|
|
||||||
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
|
|
||||||
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
|
|
||||||
| `--yes` | Skip the destructive confirmation prompt |
|
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `-c, --config <path>` | Path to config file |
|
| `-c, --config <path>` | Path to config file |
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,38 @@ If the first real publish returns npm `E404`, check npm-side prerequisites befor
|
||||||
- The initial publish must include `--access public` for a public scoped package.
|
- The initial publish must include `--access public` for a public scoped package.
|
||||||
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA.
|
||||||
|
|
||||||
|
### Manual first publish for `@paperclipai/mcp-server`
|
||||||
|
|
||||||
|
If you need to publish only the MCP server package once by hand, use:
|
||||||
|
|
||||||
|
- `@paperclipai/mcp-server`
|
||||||
|
|
||||||
|
Recommended flow from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# optional sanity check: this 404s until the first publish exists
|
||||||
|
npm view @paperclipai/mcp-server version
|
||||||
|
|
||||||
|
# make sure the build output is fresh
|
||||||
|
pnpm --filter @paperclipai/mcp-server build
|
||||||
|
|
||||||
|
# confirm your local npm auth before the real publish
|
||||||
|
npm whoami
|
||||||
|
|
||||||
|
# safe preview of the exact publish payload
|
||||||
|
cd packages/mcp-server
|
||||||
|
pnpm publish --dry-run --no-git-checks --access public
|
||||||
|
|
||||||
|
# real publish
|
||||||
|
pnpm publish --no-git-checks --access public
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Publish from `packages/mcp-server/`, not the repo root.
|
||||||
|
- If `npm view @paperclipai/mcp-server version` already returns the same version that is in [`packages/mcp-server/package.json`](../packages/mcp-server/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh).
|
||||||
|
- The same npm-side prerequisites apply as above: valid npm auth, permission to publish to the `@paperclipai` scope, `--access public`, and the required publish auth/2FA policy.
|
||||||
|
|
||||||
## Version formats
|
## Version formats
|
||||||
|
|
||||||
Paperclip uses calendar versions:
|
Paperclip uses calendar versions:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
# 2026-04-07 Issue Detail Speed And Optimistic Inventory
|
||||||
|
|
||||||
|
Status: Proposed
|
||||||
|
Date: 2026-04-07
|
||||||
|
Audience: Product and engineering
|
||||||
|
Related:
|
||||||
|
- `ui/src/pages/IssueDetail.tsx`
|
||||||
|
- `ui/src/components/IssueProperties.tsx`
|
||||||
|
- `ui/src/api/issues.ts`
|
||||||
|
- `ui/src/lib/queryKeys.ts`
|
||||||
|
- `server/src/routes/issues.ts`
|
||||||
|
- `server/src/services/issues.ts`
|
||||||
|
- [PAP-1192](/PAP/issues/PAP-1192)
|
||||||
|
- [PAP-1191](/PAP/issues/PAP-1191)
|
||||||
|
- [PAP-1188](/PAP/issues/PAP-1188)
|
||||||
|
- [PAP-1119](/PAP/issues/PAP-1119)
|
||||||
|
- [PAP-945](/PAP/issues/PAP-945)
|
||||||
|
- [PAP-1165](/PAP/issues/PAP-1165)
|
||||||
|
- [PAP-890](/PAP/issues/PAP-890)
|
||||||
|
- [PAP-254](/PAP/issues/PAP-254)
|
||||||
|
- [PAP-138](/PAP/issues/PAP-138)
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This note inventories the Paperclip issues that point to the same UX class of problem:
|
||||||
|
|
||||||
|
- pages feel slow because they over-fetch or refetch too much
|
||||||
|
- actions feel slow because the UI waits for the round trip before reflecting obvious local intent
|
||||||
|
- optimistic updates exist in some places, but not in a consistent system
|
||||||
|
|
||||||
|
The immediate trigger is [PAP-1192](/PAP/issues/PAP-1192): the issue detail page now feels very slow.
|
||||||
|
|
||||||
|
## 2. Short Answer
|
||||||
|
|
||||||
|
The issue detail page is not obviously blocked by one pathological endpoint. The main problem is the shape of the page:
|
||||||
|
|
||||||
|
- `IssueDetail` fans out into many independent queries on mount
|
||||||
|
- some of those queries fetch full company-wide collections for data that is local to one issue
|
||||||
|
- common mutations invalidate almost every issue-related query, which creates avoidable refetch storms
|
||||||
|
- the page has only a minimal top-level `Loading...` fallback and very little staged or sectional loading UX
|
||||||
|
|
||||||
|
Measured against the current assigned issue (`PAP-1191`) on local dev, the slowest single request was the full company issues list:
|
||||||
|
|
||||||
|
- `GET /api/issues/:id` about `18ms`
|
||||||
|
- `GET /api/issues/:id/comments|activity|approvals|attachments` about `6-8ms`
|
||||||
|
- `GET /api/companies/:companyId/agents|projects` about `9-11ms`
|
||||||
|
- `GET /api/companies/:companyId/issues` about `76ms`
|
||||||
|
|
||||||
|
That strongly suggests the current pain is aggregate client fan-out plus over-broad invalidation, not one obviously broken endpoint.
|
||||||
|
|
||||||
|
## 3. Similar Issue Inventory
|
||||||
|
|
||||||
|
## 3.1 Issue-detail and issue-action siblings
|
||||||
|
|
||||||
|
- [PAP-1192](/PAP/issues/PAP-1192): issue page feels like it loads forever
|
||||||
|
- [PAP-1188](/PAP/issues/PAP-1188): assignee changes in the issue properties pane were slow and needed optimistic UI
|
||||||
|
- [PAP-945](/PAP/issues/PAP-945): optimistic comment rendering
|
||||||
|
- [PAP-1003](/PAP/issues/PAP-1003): optimistic comments had duplicate draft/pending behavior
|
||||||
|
- [PAP-947](/PAP/issues/PAP-947): follow-up breakage from optimistic comments
|
||||||
|
- [PAP-254](/PAP/issues/PAP-254): long issue threads become sluggish when adding comments
|
||||||
|
- [PAP-189](/PAP/issues/PAP-189): comment semantics while an issue has a live run
|
||||||
|
|
||||||
|
Pattern: the issue page already has a history of needing both optimistic behavior and bounded thread/loading behavior. `PAP-1192` is the same family, not a new category.
|
||||||
|
|
||||||
|
## 3.2 Inbox and list-view siblings
|
||||||
|
|
||||||
|
- [PAP-1119](/PAP/issues/PAP-1119): optimistic archive had fade-out then snap-back
|
||||||
|
- [PAP-1165](/PAP/issues/PAP-1165): issue search slow
|
||||||
|
- [PAP-890](/PAP/issues/PAP-890): issue search slow, make it very fast
|
||||||
|
- [PAP-138](/PAP/issues/PAP-138): inbox loading feels stuck
|
||||||
|
- [PAP-470](/PAP/issues/PAP-470): create-issue save state felt slow and awkward
|
||||||
|
|
||||||
|
Pattern: Paperclip already has several places where the right fix was "show intent immediately, then reconcile," not "wait for refetch."
|
||||||
|
|
||||||
|
## 3.3 Broader app-loading siblings
|
||||||
|
|
||||||
|
- [PAP-472](/PAP/issues/PAP-472): dashboard charts load very slowly
|
||||||
|
- [PAP-797](/PAP/issues/PAP-797): reduce loading states through static generation/caching where possible
|
||||||
|
- [PAP-799](/PAP/issues/PAP-799): embed company data at build time to eliminate loading states
|
||||||
|
- [PAP-703](/PAP/issues/PAP-703): faster chat and better visual feedback
|
||||||
|
|
||||||
|
Pattern: the product has recurring pressure to reduce blank/loading states across the app, so the issue-detail work should fit that broader direction.
|
||||||
|
|
||||||
|
## 4. Current Issue Detail Findings
|
||||||
|
|
||||||
|
## 4.1 Mount query fan-out is high
|
||||||
|
|
||||||
|
`ui/src/pages/IssueDetail.tsx` mounts all of these data sources up front:
|
||||||
|
|
||||||
|
- issue detail
|
||||||
|
- comments
|
||||||
|
- activity
|
||||||
|
- linked runs
|
||||||
|
- linked approvals
|
||||||
|
- attachments
|
||||||
|
- live runs
|
||||||
|
- active run
|
||||||
|
- full company issues list
|
||||||
|
- agents list
|
||||||
|
- auth session
|
||||||
|
- projects list
|
||||||
|
- feedback votes
|
||||||
|
- instance general settings
|
||||||
|
- plugin slots
|
||||||
|
|
||||||
|
This is too much for the initial view of a single issue.
|
||||||
|
|
||||||
|
## 4.2 The page fetches full company issue data just to derive child issues
|
||||||
|
|
||||||
|
`IssueDetail` currently does:
|
||||||
|
|
||||||
|
- `issuesApi.list(selectedCompanyId!)`
|
||||||
|
- then filters client-side for `parentId === issue.id`
|
||||||
|
|
||||||
|
That is expensive relative to the need.
|
||||||
|
|
||||||
|
Important detail:
|
||||||
|
|
||||||
|
- the server route already supports `parentId`
|
||||||
|
- `server/src/services/issues.ts` already supports `parentId`
|
||||||
|
- but `ui/src/api/issues.ts` does not expose `parentId` in the filter type
|
||||||
|
|
||||||
|
So the client is missing an already-supported narrow query path.
|
||||||
|
|
||||||
|
## 4.3 Comments are still fetched as full-thread loads
|
||||||
|
|
||||||
|
`server/src/routes/issues.ts` and `server/src/services/issues.ts` already support:
|
||||||
|
|
||||||
|
- `after`
|
||||||
|
- `order`
|
||||||
|
- `limit`
|
||||||
|
|
||||||
|
But `IssueDetail` still calls `issuesApi.listComments(issueId)` with no cursor or limit and then re-invalidates the full thread after common comment actions.
|
||||||
|
|
||||||
|
That means we already have the server-side building blocks for incremental comment loading, but the page is not using them.
|
||||||
|
|
||||||
|
## 4.4 Cache invalidation is broader than necessary
|
||||||
|
|
||||||
|
`invalidateIssue()` in `IssueDetail` invalidates:
|
||||||
|
|
||||||
|
- detail
|
||||||
|
- activity
|
||||||
|
- runs
|
||||||
|
- approvals
|
||||||
|
- feedback votes
|
||||||
|
- attachments
|
||||||
|
- documents
|
||||||
|
- live runs
|
||||||
|
- active run
|
||||||
|
- multiple issue collections
|
||||||
|
- sidebar badges
|
||||||
|
|
||||||
|
That is acceptable for correctness, but it is expensive for perceived speed and makes optimistic work feel less stable because the page keeps re-painting from fresh network results.
|
||||||
|
|
||||||
|
## 4.5 Live run state is fetched twice
|
||||||
|
|
||||||
|
The page polls both:
|
||||||
|
|
||||||
|
- `issues.liveRuns(issueId)` every 3s
|
||||||
|
- `issues.activeRun(issueId)` every 3s
|
||||||
|
|
||||||
|
That is duplicate polling for closely related state.
|
||||||
|
|
||||||
|
## 4.6 Properties panel duplicates more list fetching
|
||||||
|
|
||||||
|
`ui/src/components/IssueProperties.tsx` fetches:
|
||||||
|
|
||||||
|
- session
|
||||||
|
- agents list
|
||||||
|
- projects list
|
||||||
|
- labels
|
||||||
|
- and, when the blocker picker opens, the full company issues list
|
||||||
|
|
||||||
|
The page and panel are each doing their own list work instead of sharing a narrower issue-detail data model.
|
||||||
|
|
||||||
|
## 4.7 The perceived loading UX is too thin
|
||||||
|
|
||||||
|
`IssueDetail` only shows:
|
||||||
|
|
||||||
|
- plain `Loading...` while the main issue query is pending
|
||||||
|
|
||||||
|
After that, many sub-sections can appear empty or incomplete until their own queries resolve. That makes the page feel slower than the raw request times suggest.
|
||||||
|
|
||||||
|
## 5. Recommended Plan
|
||||||
|
|
||||||
|
## 5.1 Phase 1: Fix perceived speed first
|
||||||
|
|
||||||
|
Ship UX changes that make the page feel immediate before deeper backend reshaping:
|
||||||
|
|
||||||
|
- replace the plain `Loading...` state with an issue-detail skeleton
|
||||||
|
- give comments, activity, attachments, and sub-issues their own skeleton/empty/loading states
|
||||||
|
- preserve visible stale data during refetch instead of clearing sections
|
||||||
|
- show explicit pending state for local actions that are already optimistic
|
||||||
|
|
||||||
|
Why first:
|
||||||
|
|
||||||
|
- it improves the user-facing feel immediately
|
||||||
|
- it reduces the chance that later data changes still feel slow because the page flashes blank
|
||||||
|
|
||||||
|
## 5.2 Phase 2: Stop fetching the full company issues list for child issues
|
||||||
|
|
||||||
|
Add `parentId` to the `issuesApi.list(...)` filter type and switch `IssueDetail` to:
|
||||||
|
|
||||||
|
- fetch child issues only
|
||||||
|
- stop loading the full company issue collection on page mount
|
||||||
|
|
||||||
|
This is the highest-confidence narrow win because the server path already exists.
|
||||||
|
|
||||||
|
## 5.3 Phase 3: Convert comments to a bounded + incremental model
|
||||||
|
|
||||||
|
Use the existing server support for:
|
||||||
|
|
||||||
|
- latest comment cursor from heartbeat context or issue bootstrap
|
||||||
|
- incremental fetch with `after`
|
||||||
|
- bounded initial fetch with `limit`
|
||||||
|
|
||||||
|
Suggested behavior:
|
||||||
|
|
||||||
|
- first load: fetch the latest N comments
|
||||||
|
- offer `load earlier` for long threads
|
||||||
|
- after posting or on live updates: append incrementally instead of invalidating the whole thread
|
||||||
|
|
||||||
|
This should address the same performance family as [PAP-254](/PAP/issues/PAP-254).
|
||||||
|
|
||||||
|
## 5.4 Phase 4: Reduce duplicate polling and invalidation
|
||||||
|
|
||||||
|
Tighten the runtime side of the page:
|
||||||
|
|
||||||
|
- collapse `liveRuns` and `activeRun` into one client source if possible
|
||||||
|
- stop invalidating unrelated issue collections after mutations that only affect the current issue
|
||||||
|
- merge server responses into cache where we already have enough information
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- posting a comment should not force a broad company issue list refetch unless list-visible metadata changed
|
||||||
|
- attachment changes should not invalidate approvals or unrelated live-run queries
|
||||||
|
|
||||||
|
## 5.5 Phase 5: Consider an issue-detail bootstrap contract
|
||||||
|
|
||||||
|
If the page is still too chatty after the client fixes, add one tailored bootstrap surface for the issue detail page.
|
||||||
|
|
||||||
|
Potential bootstrap payload:
|
||||||
|
|
||||||
|
- issue core data
|
||||||
|
- child issue summaries
|
||||||
|
- latest comment cursor and recent comment page
|
||||||
|
- live run summary
|
||||||
|
- attachment summaries
|
||||||
|
- approval summaries
|
||||||
|
- any lightweight mention/selector metadata truly needed at first paint
|
||||||
|
|
||||||
|
This should happen after the obvious client overfetch fixes, not before.
|
||||||
|
|
||||||
|
## 6. Concrete Opportunities By Surface
|
||||||
|
|
||||||
|
## 6.1 Issue detail page
|
||||||
|
|
||||||
|
- narrow child issue fetch from full list to `parentId`
|
||||||
|
- stage loading by section instead of all-or-nothing perception
|
||||||
|
- bound initial comments payload
|
||||||
|
- reduce duplicate live-run polling
|
||||||
|
- replace broad invalidation with targeted cache writes
|
||||||
|
|
||||||
|
## 6.2 Issue properties panel
|
||||||
|
|
||||||
|
- reuse page-level agents/projects data where possible
|
||||||
|
- fetch blockers lazily and narrowly
|
||||||
|
- keep local optimistic field updates without broad page invalidation
|
||||||
|
|
||||||
|
## 6.3 Thread/comment UX
|
||||||
|
|
||||||
|
- append optimistic comments directly into the visible thread
|
||||||
|
- keep queued/pending comment state stable during reconciliation
|
||||||
|
- fetch only new comments after the last known cursor
|
||||||
|
|
||||||
|
## 6.4 Cross-app optimistic consistency
|
||||||
|
|
||||||
|
The same standards should apply to:
|
||||||
|
|
||||||
|
- issue archive/unarchive
|
||||||
|
- issue property edits
|
||||||
|
- create issue/sub-issue flows
|
||||||
|
- comment posting
|
||||||
|
- attachment/document actions where the local result is obvious
|
||||||
|
|
||||||
|
## 7. Suggested Execution Order
|
||||||
|
|
||||||
|
1. `PAP-1192`: issue-detail skeletons and staged loading
|
||||||
|
2. add `parentId` support to `ui/src/api/issues.ts` and switch child-issue fetching to a narrow query
|
||||||
|
3. move comments to bounded initial load plus incremental updates
|
||||||
|
4. shrink invalidation and polling scope
|
||||||
|
5. only then decide whether a new issue-detail bootstrap endpoint is still needed
|
||||||
|
|
||||||
|
## 8. Success Criteria
|
||||||
|
|
||||||
|
This inventory is successful if the follow-up implementation makes the issue page behave like this:
|
||||||
|
|
||||||
|
1. navigating to an issue shows a shaped skeleton immediately, not plain text
|
||||||
|
2. the page no longer fetches the full company issue list just to render sub-issues
|
||||||
|
3. long threads do not require full-thread fetches on every load or comment mutation
|
||||||
|
4. local actions feel immediate and do not snap back because of broad invalidation
|
||||||
|
5. the issue page feels faster even when absolute backend timings are already reasonable
|
||||||
248
doc/plans/2026-04-07-pi-hooks-survey.md
Normal file
248
doc/plans/2026-04-07-pi-hooks-survey.md
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
# Pi Hook Survey
|
||||||
|
|
||||||
|
Status: investigation note
|
||||||
|
Date: 2026-04-07
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
We were asked to find the hook surfaces exposed by `pi` and `pi-mono`, then decide which ideas transfer cleanly into Paperclip.
|
||||||
|
|
||||||
|
This note is based on direct source inspection of:
|
||||||
|
|
||||||
|
- `badlogic/pi` default branch and `pi2` branch
|
||||||
|
- `badlogic/pi-mono` `packages/coding-agent`
|
||||||
|
- current Paperclip plugin and adapter surfaces in this repo
|
||||||
|
|
||||||
|
## Short answer
|
||||||
|
|
||||||
|
- Current `pi` does not expose a comparable extension hook API. What it exposes today is a JSON event stream from `pi-agent`.
|
||||||
|
- `pi-mono` does expose a real extension hook system. It is broad, typed, and intentionally allows mutation of agent/runtime behavior.
|
||||||
|
- Paperclip should copy only the safe subset:
|
||||||
|
- typed event subscriptions
|
||||||
|
- read-only run lifecycle events
|
||||||
|
- explicit worker lifecycle hooks
|
||||||
|
- plugin-to-plugin events
|
||||||
|
- Paperclip should not copy the dangerous subset:
|
||||||
|
- arbitrary mutation hooks on core control-plane decisions
|
||||||
|
- project-local plugin loading
|
||||||
|
- built-in tool shadowing by name collision
|
||||||
|
|
||||||
|
## What `pi` has today
|
||||||
|
|
||||||
|
Current `badlogic/pi` is primarily a GPU pod manager plus a lightweight agent runner. It does not expose a `pi.on(...)`-style extension API like `pi-mono`.
|
||||||
|
|
||||||
|
The closest thing to hooks is the `pi-agent --json` event stream:
|
||||||
|
|
||||||
|
- `session_start`
|
||||||
|
- `user_message`
|
||||||
|
- `assistant_start`
|
||||||
|
- `assistant_message`
|
||||||
|
- `thinking`
|
||||||
|
- `tool_call`
|
||||||
|
- `tool_result`
|
||||||
|
- `token_usage`
|
||||||
|
- `error`
|
||||||
|
- `interrupted`
|
||||||
|
|
||||||
|
That makes `pi` useful as an event producer, but not as a host for third-party runtime interception.
|
||||||
|
|
||||||
|
## What `pi-mono` has
|
||||||
|
|
||||||
|
`pi-mono` exposes a real extension API through `packages/coding-agent/src/core/extensions/types.ts`.
|
||||||
|
|
||||||
|
### Extension event hooks
|
||||||
|
|
||||||
|
Verified `pi.on(...)` hook names:
|
||||||
|
|
||||||
|
- `resources_discover`
|
||||||
|
- `session_start`
|
||||||
|
- `session_before_switch`
|
||||||
|
- `session_before_fork`
|
||||||
|
- `session_before_compact`
|
||||||
|
- `session_compact`
|
||||||
|
- `session_shutdown`
|
||||||
|
- `session_before_tree`
|
||||||
|
- `session_tree`
|
||||||
|
- `context`
|
||||||
|
- `before_provider_request`
|
||||||
|
- `before_agent_start`
|
||||||
|
- `agent_start`
|
||||||
|
- `agent_end`
|
||||||
|
- `turn_start`
|
||||||
|
- `turn_end`
|
||||||
|
- `message_start`
|
||||||
|
- `message_update`
|
||||||
|
- `message_end`
|
||||||
|
- `tool_execution_start`
|
||||||
|
- `tool_execution_update`
|
||||||
|
- `tool_execution_end`
|
||||||
|
- `model_select`
|
||||||
|
- `tool_call`
|
||||||
|
- `tool_result`
|
||||||
|
- `user_bash`
|
||||||
|
- `input`
|
||||||
|
|
||||||
|
### Other extension surfaces
|
||||||
|
|
||||||
|
`pi-mono` extensions can also:
|
||||||
|
|
||||||
|
- `registerTool(...)`
|
||||||
|
- `registerCommand(...)`
|
||||||
|
- `registerShortcut(...)`
|
||||||
|
- `registerFlag(...)`
|
||||||
|
- `registerMessageRenderer(...)`
|
||||||
|
- `registerProvider(...)`
|
||||||
|
- `unregisterProvider(...)`
|
||||||
|
- use an inter-extension event bus via `pi.events`
|
||||||
|
|
||||||
|
### Important behavior
|
||||||
|
|
||||||
|
`pi-mono` hooks are not just observers. Several can actively mutate behavior:
|
||||||
|
|
||||||
|
- `before_agent_start` can rewrite the effective system prompt and inject messages
|
||||||
|
- `context` can replace the message set before an LLM call
|
||||||
|
- `before_provider_request` can rewrite the serialized provider payload
|
||||||
|
- `tool_call` can mutate tool inputs and block execution
|
||||||
|
- `tool_result` can rewrite tool output
|
||||||
|
- `user_bash` can replace shell execution entirely
|
||||||
|
- `input` can transform or fully handle user input before normal processing
|
||||||
|
|
||||||
|
That is a good fit for a local coding harness. It is not automatically a good fit for a company control plane.
|
||||||
|
|
||||||
|
## What Paperclip already has
|
||||||
|
|
||||||
|
Paperclip already has several hook-like surfaces, but they are much narrower and safer:
|
||||||
|
|
||||||
|
- plugin worker lifecycle hooks such as `setup()` and `onHealth()`
|
||||||
|
- declared webhook endpoints for plugins
|
||||||
|
- scheduled jobs
|
||||||
|
- a typed plugin event bus with filtering and plugin namespacing
|
||||||
|
- adapter runtime hooks for logs/status/usage in the run pipeline
|
||||||
|
|
||||||
|
The plugin event bus is already pointed in the right direction:
|
||||||
|
|
||||||
|
- core domain events can be subscribed to
|
||||||
|
- filters are applied server-side
|
||||||
|
- plugin-emitted events are namespaced under `plugin.<pluginId>.*`
|
||||||
|
- plugins do not override core behavior by name collision
|
||||||
|
|
||||||
|
## What transfers well to Paperclip
|
||||||
|
|
||||||
|
These ideas from `pi-mono` fit Paperclip with little conceptual risk:
|
||||||
|
|
||||||
|
### 1. Read-only run lifecycle subscriptions
|
||||||
|
|
||||||
|
Paperclip should continue exposing run and transcript events to plugins, for example:
|
||||||
|
|
||||||
|
- run started / finished
|
||||||
|
- tool started / finished
|
||||||
|
- usage reported
|
||||||
|
- issue comment created
|
||||||
|
|
||||||
|
This matches Paperclip's control-plane posture: observe, react, automate.
|
||||||
|
|
||||||
|
### 2. Plugin-to-plugin events
|
||||||
|
|
||||||
|
Paperclip already has this. It is worth keeping and extending.
|
||||||
|
|
||||||
|
This is the clean replacement for many ad hoc hook chains.
|
||||||
|
|
||||||
|
### 3. Explicit worker lifecycle hooks
|
||||||
|
|
||||||
|
Paperclip already has `setup()` and `onHealth()`. That is the right shape.
|
||||||
|
|
||||||
|
If more lifecycle is needed, it should stay explicit and host-controlled.
|
||||||
|
|
||||||
|
### 4. Trusted adapter-level prompt/runtime middleware
|
||||||
|
|
||||||
|
Some `pi-mono` ideas do belong in Paperclip, but only inside trusted adapter/runtime code:
|
||||||
|
|
||||||
|
- prompt shaping before a run starts
|
||||||
|
- provider request customization
|
||||||
|
- tool execution wrappers for local coding adapters
|
||||||
|
|
||||||
|
This should be an adapter surface, not a general company plugin surface.
|
||||||
|
|
||||||
|
## What should not transfer directly
|
||||||
|
|
||||||
|
These `pi-mono` capabilities are a bad fit for Paperclip core:
|
||||||
|
|
||||||
|
### 1. Arbitrary mutation hooks on control-plane decisions
|
||||||
|
|
||||||
|
Paperclip should not let general plugins rewrite:
|
||||||
|
|
||||||
|
- issue checkout semantics
|
||||||
|
- approval outcomes
|
||||||
|
- budget enforcement
|
||||||
|
- assignment rules
|
||||||
|
- company scoping
|
||||||
|
|
||||||
|
Those are core invariants.
|
||||||
|
|
||||||
|
### 2. Tool shadowing by name collision
|
||||||
|
|
||||||
|
`pi-mono`'s low-friction override model is great for a personal coding harness.
|
||||||
|
|
||||||
|
Paperclip should keep plugin tools namespaced and non-shadowing.
|
||||||
|
|
||||||
|
### 3. Project-local plugin loading
|
||||||
|
|
||||||
|
Paperclip is an operator-controlled control plane. Repo-local plugin auto-loading would make behavior too implicit and too hard to govern.
|
||||||
|
|
||||||
|
### 4. UI-session-specific hooks as first-class product surface
|
||||||
|
|
||||||
|
Hooks like:
|
||||||
|
|
||||||
|
- `session_before_switch`
|
||||||
|
- `session_before_fork`
|
||||||
|
- `session_before_tree`
|
||||||
|
- `model_select`
|
||||||
|
- `input`
|
||||||
|
- `user_bash`
|
||||||
|
|
||||||
|
are tied to `pi-mono` being an interactive terminal coding harness.
|
||||||
|
|
||||||
|
They do not map directly to Paperclip's board-and-issues model.
|
||||||
|
|
||||||
|
## Recommended Paperclip direction
|
||||||
|
|
||||||
|
If we want a "hooks" story inspired by `pi-mono`, it should split into two layers:
|
||||||
|
|
||||||
|
### Layer 1: safe control-plane plugins
|
||||||
|
|
||||||
|
Allowed surfaces:
|
||||||
|
|
||||||
|
- typed domain event subscriptions
|
||||||
|
- jobs
|
||||||
|
- webhooks
|
||||||
|
- plugin-to-plugin events
|
||||||
|
- UI slots and bridge actions
|
||||||
|
- plugin-owned tools and data endpoints
|
||||||
|
|
||||||
|
Disallowed:
|
||||||
|
|
||||||
|
- mutation of core issue/approval/budget invariants
|
||||||
|
|
||||||
|
### Layer 2: trusted runtime middleware
|
||||||
|
|
||||||
|
For adapters and other trusted runtime packages only:
|
||||||
|
|
||||||
|
- prompt assembly hooks
|
||||||
|
- provider payload hooks
|
||||||
|
- tool execution wrappers
|
||||||
|
- transcript rendering helpers
|
||||||
|
|
||||||
|
This is where the best `pi-mono` runtime ideas belong.
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
If the question is "what hooks do `pi` and `pi-mono` have?":
|
||||||
|
|
||||||
|
- `pi`: JSON output events, not a general extension hook system
|
||||||
|
- `pi-mono`: a broad extension hook API with 27 named event hooks plus tool/command/provider registration
|
||||||
|
|
||||||
|
If the question is "what works for Paperclip too?":
|
||||||
|
|
||||||
|
- yes: typed event subscriptions, worker lifecycle hooks, namespaced plugin events, read-only run lifecycle events
|
||||||
|
- maybe, but trusted-only: prompt/provider/tool middleware around adapter execution
|
||||||
|
- no: arbitrary mutation hooks on control-plane invariants, project-local plugin loading, tool shadowing
|
||||||
238
doc/plans/2026-04-08-agent-browser-process-cleanup-plan.md
Normal file
238
doc/plans/2026-04-08-agent-browser-process-cleanup-plan.md
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
# PAP-1231 Agent Browser Process Cleanup Plan
|
||||||
|
|
||||||
|
Status: Proposed
|
||||||
|
Date: 2026-04-08
|
||||||
|
Related issue: `PAP-1231`
|
||||||
|
Audience: Engineering
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Explain why browser processes accumulate during local agent runs and define a cleanup plan that fixes the general process-ownership problem rather than treating `agent-browser` as a one-off.
|
||||||
|
|
||||||
|
## Short answer
|
||||||
|
|
||||||
|
Yes, there is a likely root cause in Paperclip's local execution model.
|
||||||
|
|
||||||
|
Today, heartbeat-run local adapters persist and manage only the top-level spawned PID. Their timeout/cancel path uses direct `child.kill()` semantics. That is weaker than the runtime-service path, which already tracks and terminates whole process groups.
|
||||||
|
|
||||||
|
If Codex, Claude, Cursor, or a skill launched through them starts Chrome or Chromium helpers, Paperclip can lose ownership of those descendants even when it still believes it handled the run correctly.
|
||||||
|
|
||||||
|
## Observed implementation facts
|
||||||
|
|
||||||
|
### 1. Heartbeat-run local adapters track only one PID
|
||||||
|
|
||||||
|
`packages/adapter-utils/src/server-utils.ts`
|
||||||
|
|
||||||
|
- `runChildProcess()` spawns the adapter command and records only `child.pid`
|
||||||
|
- timeout handling sends `SIGTERM` and then `SIGKILL` to the direct child
|
||||||
|
- there is no process-group creation or process-group kill path there today
|
||||||
|
|
||||||
|
`packages/db/src/schema/heartbeat_runs.ts`
|
||||||
|
|
||||||
|
- `heartbeat_runs` stores `process_pid`
|
||||||
|
- there is no persisted `process_group_id`
|
||||||
|
|
||||||
|
`server/src/services/heartbeat.ts`
|
||||||
|
|
||||||
|
- cancellation logic uses the in-memory child handle and calls `child.kill()`
|
||||||
|
- orphaned-run recovery checks whether the recorded direct PID is alive
|
||||||
|
- the recovery model is built around one tracked process, not a descendant tree
|
||||||
|
|
||||||
|
### 2. Workspace runtime already uses stronger ownership
|
||||||
|
|
||||||
|
`server/src/services/workspace-runtime.ts`
|
||||||
|
|
||||||
|
- runtime services are spawned with `detached: process.platform !== "win32"`
|
||||||
|
- the service record stores `processGroupId`
|
||||||
|
- shutdown calls `terminateLocalService()` with group-aware killing
|
||||||
|
|
||||||
|
`server/src/services/local-service-supervisor.ts`
|
||||||
|
|
||||||
|
- `terminateLocalService()` prefers `process.kill(-processGroupId, signal)` on POSIX
|
||||||
|
- it escalates from `SIGTERM` to `SIGKILL`
|
||||||
|
|
||||||
|
This is the clearest internal comparison point: Paperclip already has one local-process subsystem that treats process-group ownership as the right abstraction.
|
||||||
|
|
||||||
|
### 3. The current recovery path explains why leaks would be visible but hard to reason about
|
||||||
|
|
||||||
|
If the direct adapter process exits, hangs, or is cancelled after launching a browser subtree:
|
||||||
|
|
||||||
|
- Paperclip may think it cancelled the run because the parent process is gone
|
||||||
|
- descendant Chrome helpers may still be running
|
||||||
|
- orphan recovery has no persisted process-group identity to reconcile or reap later
|
||||||
|
|
||||||
|
That makes the failure look like an `agent-browser` problem when the more general bug is "executor descendants are not owned strongly enough."
|
||||||
|
|
||||||
|
## Why `agent-browser` makes the problem obvious
|
||||||
|
|
||||||
|
Inference:
|
||||||
|
|
||||||
|
- Chromium is intentionally multi-process
|
||||||
|
- browser automation often leaves a browser process plus renderer, GPU, utility, and crashpad/helper children
|
||||||
|
- skills that open browsers repeatedly amplify the symptom because each run can produce several descendant processes
|
||||||
|
|
||||||
|
So `agent-browser` is probably not the root cause. It is the workload that exposes the weak ownership model fastest.
|
||||||
|
|
||||||
|
## Success condition
|
||||||
|
|
||||||
|
This work is successful when Paperclip can:
|
||||||
|
|
||||||
|
1. start a local adapter run and own the full descendant tree it created
|
||||||
|
2. cancel, timeout, or recover that run without leaving Chrome descendants behind on POSIX
|
||||||
|
3. detect and clean up stale local descendants after server restarts
|
||||||
|
4. expose enough metadata that operators can see which run owns which spawned process tree
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not:
|
||||||
|
|
||||||
|
- special-case `agent-browser` only
|
||||||
|
- depend on manual `pkill chrome` cleanup as the primary fix
|
||||||
|
- require every skill author to add bespoke browser teardown logic before Paperclip can clean up correctly
|
||||||
|
- change remote/http adapter behavior as part of the first pass
|
||||||
|
|
||||||
|
## Proposed plan
|
||||||
|
|
||||||
|
### Phase 0: reproduce and instrument
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- make the leak measurable from Paperclip's side before changing execution semantics
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- add a reproducible local test script or fixture that launches a child process which itself launches descendants and ignores normal parent exit
|
||||||
|
- capture parent PID, descendant PIDs, and run ID in logs during local adapter execution
|
||||||
|
- document current behavior separately for:
|
||||||
|
- normal completion
|
||||||
|
- timeout
|
||||||
|
- explicit cancellation
|
||||||
|
- server restart during run
|
||||||
|
|
||||||
|
Deliverable:
|
||||||
|
|
||||||
|
- one short repro note attached to the implementation issue or child issue
|
||||||
|
|
||||||
|
### Phase 1: give heartbeat-run local adapters process-group ownership
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- align adapter-run execution with the stronger runtime-service model
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- update `runChildProcess()` to create a dedicated process group on POSIX
|
||||||
|
- persist both:
|
||||||
|
- direct PID
|
||||||
|
- process-group ID
|
||||||
|
- update the run cancellation and timeout paths to kill the group first, then escalate
|
||||||
|
- keep direct-PID fallback behavior for platforms where group kill is not available
|
||||||
|
|
||||||
|
Likely touched surfaces:
|
||||||
|
|
||||||
|
- `packages/adapter-utils/src/server-utils.ts`
|
||||||
|
- `packages/db/src/schema/heartbeat_runs.ts`
|
||||||
|
- `packages/shared/src/types/heartbeat.ts`
|
||||||
|
- `server/src/services/heartbeat.ts`
|
||||||
|
|
||||||
|
Important design choice:
|
||||||
|
|
||||||
|
- use the same ownership model for all local child-process adapters, not just Codex or Claude
|
||||||
|
|
||||||
|
### Phase 2: make restart recovery group-aware
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- prevent stale descendants from surviving server crashes or restarts indefinitely
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- teach orphan reconciliation to inspect the persisted process-group ID, not only the direct PID
|
||||||
|
- if the direct parent is gone but the group still exists, mark the run as detached-orphaned with clearer metadata
|
||||||
|
- decide whether restart recovery should:
|
||||||
|
- adopt the still-running group, or
|
||||||
|
- terminate it as unrecoverable
|
||||||
|
|
||||||
|
Recommendation:
|
||||||
|
|
||||||
|
- for heartbeat runs, prefer terminating unrecoverable orphan groups rather than adopting them unless we can prove the adapter session remains safe and observable
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
- runtime services are long-lived and adoptable
|
||||||
|
- heartbeat runs are task executions with stricter audit and cancellation semantics
|
||||||
|
|
||||||
|
### Phase 3: add operator-visible cleanup tools
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- make the system diagnosable when ownership still fails
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- surface the tracked process metadata in run details or debug endpoints
|
||||||
|
- add a control-plane cleanup action or CLI utility for stale local run processes owned by Paperclip
|
||||||
|
- scope cleanup by run/agent/company instead of broad browser-name matching
|
||||||
|
|
||||||
|
This should replace ad hoc scripts as the general-purpose escape hatch.
|
||||||
|
|
||||||
|
### Phase 4: cover platform and regression cases
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- keep the fix from regressing and define platform behavior explicitly
|
||||||
|
|
||||||
|
Tests to add:
|
||||||
|
|
||||||
|
- unit tests around process-group-aware cancellation in adapter execution utilities
|
||||||
|
- heartbeat recovery tests for:
|
||||||
|
- surviving descendant tree after parent loss
|
||||||
|
- timeout cleanup
|
||||||
|
- cancellation cleanup
|
||||||
|
- platform-conditional behavior notes for Windows, where negative-PID group kill does not apply
|
||||||
|
|
||||||
|
## Recommended first implementation slice
|
||||||
|
|
||||||
|
The first shipping slice should be narrow:
|
||||||
|
|
||||||
|
1. introduce process-group ownership for local heartbeat-run adapters on POSIX
|
||||||
|
2. persist group metadata on `heartbeat_runs`
|
||||||
|
3. switch timeout/cancel paths from direct-child kill to group kill
|
||||||
|
4. add one regression test that proves descendants die with the parent run
|
||||||
|
|
||||||
|
That should address the main Chrome accumulation path without taking on the full restart-recovery design in the same patch.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
### 1. Over-killing unrelated processes
|
||||||
|
|
||||||
|
If process-group boundaries are created incorrectly, cleanup could terminate more than the run owns.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- create a fresh process group only for the spawned adapter command
|
||||||
|
- persist and target that exact group
|
||||||
|
|
||||||
|
### 2. Cross-platform differences
|
||||||
|
|
||||||
|
Windows does not support the POSIX negative-PID kill pattern used elsewhere in the repo.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- ship POSIX-first
|
||||||
|
- keep direct-child fallback on Windows
|
||||||
|
- document Windows as partial until job-object or equivalent handling is designed
|
||||||
|
|
||||||
|
### 3. Session recovery complexity
|
||||||
|
|
||||||
|
Adopting a still-running orphaned group may look attractive but can break observability if stdout/stderr pipes are already gone.
|
||||||
|
|
||||||
|
Mitigation:
|
||||||
|
|
||||||
|
- default to deterministic cleanup for heartbeat runs unless adoption is explicitly proven safe
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
Treat this as a Paperclip executor ownership bug, not an `agent-browser` bug.
|
||||||
|
|
||||||
|
`agent-browser` should remain a useful repro case, but the implementation should be shared across all local child-process adapters so any descendant process tree spawned by Codex, Claude, Cursor, Gemini, Pi, or OpenCode is owned and cleaned up consistently.
|
||||||
261
doc/plans/2026-04-08-agent-os-follow-up-plan.md
Normal file
261
doc/plans/2026-04-08-agent-os-follow-up-plan.md
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
# PAP-1229 Agent OS Follow-up Plan
|
||||||
|
|
||||||
|
Date: 2026-04-08
|
||||||
|
Related issue: `PAP-1229`
|
||||||
|
Companion analysis: `doc/plans/2026-04-08-agent-os-technical-report.md`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Turn the `agent-os` research into a low-risk Paperclip execution plan that preserves Paperclip's control-plane model while testing the few runtime ideas that appear worth adopting.
|
||||||
|
|
||||||
|
## Decision summary
|
||||||
|
|
||||||
|
Paperclip should not absorb `agent-os` as a product model or orchestration layer.
|
||||||
|
|
||||||
|
Paperclip should evaluate `agent-os` in three narrow areas:
|
||||||
|
|
||||||
|
1. optional agent runtime for selected local adapters
|
||||||
|
2. capability-based runtime permission vocabulary
|
||||||
|
3. snapshot-backed disposable execution roots
|
||||||
|
|
||||||
|
Everything else should stay out of scope unless those three experiments produce strong evidence.
|
||||||
|
|
||||||
|
## Success condition
|
||||||
|
|
||||||
|
This work is successful when Paperclip has:
|
||||||
|
|
||||||
|
- a clear yes/no answer on whether `agent-os` is worth supporting as an execution substrate
|
||||||
|
- a concrete adapter/runtime experiment with measurable results
|
||||||
|
- a proposed runtime capability model that fits current Paperclip adapters
|
||||||
|
- a clear decision on whether snapshot-backed execution roots are worth integrating
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Do not:
|
||||||
|
|
||||||
|
- replace Paperclip heartbeats, issues, comments, approvals, or budgets with `agent-os` primitives
|
||||||
|
- introduce Rust/sidecar requirements for all local execution paths
|
||||||
|
- migrate all adapters at once
|
||||||
|
- add runtime workflow/queue abstractions to Paperclip core
|
||||||
|
|
||||||
|
## Existing Paperclip integration points
|
||||||
|
|
||||||
|
The plan should stay anchored to these existing surfaces:
|
||||||
|
|
||||||
|
- `packages/adapter-utils/src/types.ts`
|
||||||
|
- adapter contract, runtime service reporting, session metadata, and capability normalization targets
|
||||||
|
- `server/src/services/heartbeat.ts`
|
||||||
|
- execution entry point, log capture, issue comment summaries, and cost reporting
|
||||||
|
- `server/src/services/execution-workspaces.ts`
|
||||||
|
- current workspace lifecycle and git-oriented cleanup/readiness model
|
||||||
|
- `server/src/services/plugin-loader.ts`
|
||||||
|
- typed host capability boundary and extension loading patterns
|
||||||
|
- local adapter implementations in `packages/adapters/*/src/server/`
|
||||||
|
- current execution behavior to compare against an `agent-os`-backed path
|
||||||
|
|
||||||
|
## Phase plan
|
||||||
|
|
||||||
|
### Phase 0: constraints and experiment design
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- make the evaluation falsifiable before writing integration code
|
||||||
|
|
||||||
|
Deliverables:
|
||||||
|
|
||||||
|
- short experiment brief added to this document or a child issue
|
||||||
|
- chosen first runtime target: `pi_local` or `opencode_local`
|
||||||
|
- baseline metrics definition
|
||||||
|
|
||||||
|
Questions to lock down:
|
||||||
|
|
||||||
|
- what exact developer experience should improve
|
||||||
|
- what security/isolation property we expect to gain
|
||||||
|
- what failure modes are unacceptable
|
||||||
|
- whether the prototype is adapter-only or a deeper internal runtime abstraction spike
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- a single first target chosen
|
||||||
|
- measurable comparison criteria agreed on
|
||||||
|
|
||||||
|
Recommended metrics:
|
||||||
|
|
||||||
|
- cold start latency
|
||||||
|
- session resume reliability across heartbeats
|
||||||
|
- transcript/log quality
|
||||||
|
- implementation complexity
|
||||||
|
- operational complexity on local dev machines
|
||||||
|
|
||||||
|
### Phase 1: `agentos_local` spike
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- prove that Paperclip can drive one local agent through an `agent-os` runtime without breaking heartbeat semantics
|
||||||
|
|
||||||
|
Suggested scope:
|
||||||
|
|
||||||
|
- implement a new experimental adapter, `agentos_local`, or a feature-flagged runtime path under one existing adapter
|
||||||
|
- start with `pi_local` or `opencode_local`
|
||||||
|
- keep Paperclip's existing heartbeat, issue, workspace, and comment flow authoritative
|
||||||
|
|
||||||
|
Minimum implementation shape:
|
||||||
|
|
||||||
|
- adapter accepts model/runtime config
|
||||||
|
- `server/src/services/heartbeat.ts` still owns run lifecycle
|
||||||
|
- execution result still maps into existing `AdapterExecutionResult`
|
||||||
|
- session state still fits current `sessionParams` / `sessionDisplayId` flow
|
||||||
|
|
||||||
|
What to verify:
|
||||||
|
|
||||||
|
- checkout and heartbeat flow still work end to end
|
||||||
|
- resume across multiple heartbeats works
|
||||||
|
- logs/transcripts remain readable in the UI
|
||||||
|
- failure paths surface cleanly in issue comments and run logs
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- one agent type can run reliably through the new path
|
||||||
|
- documented comparison against the existing local adapter path
|
||||||
|
- explicit recommendation: continue, pause, or abandon
|
||||||
|
|
||||||
|
### Phase 2: capability-based runtime permissions
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- introduce a Paperclip-native capability vocabulary without coupling the product to `agent-os`
|
||||||
|
|
||||||
|
Suggested scope:
|
||||||
|
|
||||||
|
- extend adapter config schema vocabulary for runtime permissions
|
||||||
|
- prototype normalized capabilities such as:
|
||||||
|
- `fs.read`
|
||||||
|
- `fs.write`
|
||||||
|
- `network.fetch`
|
||||||
|
- `network.listen`
|
||||||
|
- `process.spawn`
|
||||||
|
- `env.read`
|
||||||
|
|
||||||
|
Integration targets:
|
||||||
|
|
||||||
|
- `packages/adapter-utils/src/types.ts`
|
||||||
|
- adapter config-schema support
|
||||||
|
- server-side runtime config validation
|
||||||
|
- future board-facing UI for permissions, if needed
|
||||||
|
|
||||||
|
What to avoid:
|
||||||
|
|
||||||
|
- building a full human policy UI before the vocabulary is proven useful
|
||||||
|
- forcing every adapter to implement capability enforcement immediately
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- documented capability schema
|
||||||
|
- one adapter path using it meaningfully
|
||||||
|
- clear compatibility story for non-`agent-os` adapters
|
||||||
|
|
||||||
|
### Phase 3: snapshot-backed execution root experiment
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- determine whether a layered/snapshotted root model improves some Paperclip workloads
|
||||||
|
|
||||||
|
Suggested scope:
|
||||||
|
|
||||||
|
- evaluate it only for disposable or non-repo-heavy tasks first
|
||||||
|
- keep git worktree-based repo editing as the default for codebase tasks
|
||||||
|
|
||||||
|
Promising use cases:
|
||||||
|
|
||||||
|
- routine-style runs
|
||||||
|
- ephemeral preview/test environments
|
||||||
|
- isolated document/artifact generation
|
||||||
|
- tasks that do not need full git history or branch semantics
|
||||||
|
|
||||||
|
Integration targets:
|
||||||
|
|
||||||
|
- `server/src/services/execution-workspaces.ts`
|
||||||
|
- workspace realization paths called from `server/src/services/heartbeat.ts`
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- clear statement on which workload classes benefit
|
||||||
|
- clear statement on which workloads should stay on worktrees
|
||||||
|
- go/no-go decision for broader implementation
|
||||||
|
|
||||||
|
### Phase 4: typed host tool evaluation
|
||||||
|
|
||||||
|
Objective:
|
||||||
|
|
||||||
|
- identify where Paperclip should prefer explicit typed tools over ambient shell access
|
||||||
|
|
||||||
|
Suggested scope:
|
||||||
|
|
||||||
|
- compare `agent-os` host-toolkit ideas with existing plugin and runtime-service surfaces
|
||||||
|
- choose 1-2 sensitive operations that should become typed tools
|
||||||
|
|
||||||
|
Good candidates:
|
||||||
|
|
||||||
|
- git metadata/status inspection
|
||||||
|
- runtime service inspection
|
||||||
|
- deployment/preview status retrieval
|
||||||
|
- generated artifact publishing
|
||||||
|
|
||||||
|
Exit criteria:
|
||||||
|
|
||||||
|
- one concrete proposal for typed-tool adoption in Paperclip
|
||||||
|
- clear statement on whether this belongs in plugins, adapters, or core services
|
||||||
|
|
||||||
|
## Recommended sequencing
|
||||||
|
|
||||||
|
Recommended order:
|
||||||
|
|
||||||
|
1. Phase 0
|
||||||
|
2. Phase 1
|
||||||
|
3. Phase 2
|
||||||
|
4. Phase 3
|
||||||
|
5. Phase 4
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
|
||||||
|
- Phase 1 is the fastest way to invalidate or validate the entire `agent-os` direction
|
||||||
|
- Phase 2 is valuable even if Phase 1 is abandoned
|
||||||
|
- Phase 3 should wait until there is confidence that the runtime approach is operationally worthwhile
|
||||||
|
- Phase 4 is useful independently but should be informed by what Phase 1 and Phase 2 expose
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
### Technical risk
|
||||||
|
|
||||||
|
- `agent-os` introduces Rust sidecar and packaging complexity that may outweigh runtime benefits
|
||||||
|
|
||||||
|
### Product risk
|
||||||
|
|
||||||
|
- runtime experimentation could blur the boundary between Paperclip as control plane and Paperclip as execution platform
|
||||||
|
|
||||||
|
### Integration risk
|
||||||
|
|
||||||
|
- session semantics, log formatting, and failure behavior may degrade relative to current local adapters
|
||||||
|
|
||||||
|
### Scope risk
|
||||||
|
|
||||||
|
- a small runtime spike could expand into an adapter-system rewrite if not kept tightly bounded
|
||||||
|
|
||||||
|
## Guardrails
|
||||||
|
|
||||||
|
To keep this effort controlled:
|
||||||
|
|
||||||
|
- keep all experiments behind a clearly experimental adapter or feature flag
|
||||||
|
- do not change issue/comment/approval/budget semantics to suit the runtime
|
||||||
|
- measure against current local adapters instead of judging in isolation
|
||||||
|
- stop after Phase 1 if the operational burden is already clearly too high
|
||||||
|
|
||||||
|
## Proposed next action
|
||||||
|
|
||||||
|
The next concrete action should be a small implementation spike issue:
|
||||||
|
|
||||||
|
- title: `Prototype experimental agentos_local runtime for one local adapter`
|
||||||
|
- target adapter: `opencode_local` unless `pi_local` is materially easier
|
||||||
|
- expected output: code spike, short verification notes, and a continue/stop recommendation
|
||||||
|
|
||||||
|
If leadership wants planning only and no spike yet, this document is the handoff artifact for that decision.
|
||||||
397
doc/plans/2026-04-08-agent-os-technical-report.md
Normal file
397
doc/plans/2026-04-08-agent-os-technical-report.md
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
# Agent OS Technical Report for Paperclip
|
||||||
|
|
||||||
|
Date: 2026-04-08
|
||||||
|
Analyzed upstream: `rivet-dev/agent-os` at commit `0063cdccd1dcb1c8e211670cd05482d70d26a5c4` (`0063cdc`), dated 2026-04-06
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
`agent-os` is not a competitor to Paperclip's core product. It is an execution substrate: an embedded, VM-like runtime for agents, tools, filesystems, and session orchestration. Paperclip is a control plane: company scoping, task hierarchy, approvals, budgets, activity logs, workspaces, and governance.
|
||||||
|
|
||||||
|
The strongest takeaway is not "copy agent-os wholesale." The strongest takeaway is that Paperclip could selectively use its runtime ideas to improve local agent execution safety, reproducibility, and portability while keeping all company/task/governance logic in Paperclip.
|
||||||
|
|
||||||
|
My recommendation is:
|
||||||
|
|
||||||
|
1. Do not merge agent-os concepts into the Paperclip core product model.
|
||||||
|
2. Do evaluate an optional `agentos_local` execution adapter or internal runtime experiment.
|
||||||
|
3. Borrow a few design patterns aggressively:
|
||||||
|
- layered/snapshotted execution filesystems
|
||||||
|
- explicit capability-based runtime permissions
|
||||||
|
- a better host-tools bridge for controlled tool execution
|
||||||
|
- a normalized session capability model for agent adapters
|
||||||
|
4. Do not import its workflow/cron/queue abstractions into Paperclip core until they are reconciled with Paperclip's issue/comment/governance model.
|
||||||
|
|
||||||
|
## What agent-os actually is
|
||||||
|
|
||||||
|
From the repo layout and implementation, `agent-os` is a mixed TypeScript/Rust system that provides:
|
||||||
|
|
||||||
|
- an `AgentOs` TypeScript API for creating isolated agent VMs
|
||||||
|
- a Rust kernel/sidecar that virtualizes filesystem, processes, PTYs, pipes, permissions, and networking
|
||||||
|
- an ACP-based session model for agent runtimes such as Pi, OpenCode, and Claude-style adapters
|
||||||
|
- a registry of WASM command packages and mount plugins
|
||||||
|
- optional host toolkits, cron scheduling, and filesystem mounts
|
||||||
|
|
||||||
|
The repo is substantial already:
|
||||||
|
|
||||||
|
- monorepo with `packages/`, `crates/`, and `registry/`
|
||||||
|
- roughly 1,200 files just across `packages/`, `crates/`, and `registry/`
|
||||||
|
- mixed implementation model: TypeScript public API plus Rust kernel/sidecar internals
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
### 1. Public runtime surface
|
||||||
|
|
||||||
|
The main API lives in `packages/core/src/agent-os.ts` and exports an `AgentOs` class with methods such as:
|
||||||
|
|
||||||
|
- `create()`
|
||||||
|
- `createSession()`
|
||||||
|
- `prompt()`
|
||||||
|
- `exec()`
|
||||||
|
- `spawn()`
|
||||||
|
- `snapshotRootFilesystem()`
|
||||||
|
- cron scheduling helpers
|
||||||
|
|
||||||
|
This is an execution API, not a coordination API.
|
||||||
|
|
||||||
|
### 2. Virtualized kernel model
|
||||||
|
|
||||||
|
The kernel is implemented in Rust under `crates/kernel/src/`. It models:
|
||||||
|
|
||||||
|
- virtual filesystem
|
||||||
|
- process table
|
||||||
|
- PTYs and pipes
|
||||||
|
- resource accounting
|
||||||
|
- permissioned filesystem access
|
||||||
|
- network permission checks
|
||||||
|
|
||||||
|
That gives `agent-os` a much stronger isolation story than Paperclip's current "launch a host CLI in a workspace" local adapter approach.
|
||||||
|
|
||||||
|
### 3. Layered filesystem and snapshots
|
||||||
|
|
||||||
|
The filesystem design is one of the most reusable ideas. `agent-os` uses:
|
||||||
|
|
||||||
|
- a bundled base filesystem
|
||||||
|
- a writable overlay
|
||||||
|
- optional mounted filesystems
|
||||||
|
- snapshot export/import for reusing root states
|
||||||
|
|
||||||
|
This is cleaner than treating every execution workspace as a mutable checkout plus ad hoc cleanup. It enables reproducible starting states and cheap isolation.
|
||||||
|
|
||||||
|
### 4. Capability-based permissions
|
||||||
|
|
||||||
|
The kernel-level permission vocabulary is strong and concrete:
|
||||||
|
|
||||||
|
- filesystem operations
|
||||||
|
- network operations
|
||||||
|
- child-process execution
|
||||||
|
- environment access
|
||||||
|
|
||||||
|
The Rust kernel defaults are deny-oriented, but the high-level JS API currently serializes permissive defaults unless the caller provides a policy. That is an important nuance: the primitive is security-minded, but the product surface is still convenience-first.
|
||||||
|
|
||||||
|
### 5. Host-tools bridge
|
||||||
|
|
||||||
|
`agent-os` exposes host-side tools via a toolkit abstraction (`hostTool`, `toolKit`) and a local RPC bridge. This is a strong pattern because it gives the agent explicit, typed tools rather than ambient shell access to everything on the host.
|
||||||
|
|
||||||
|
### 6. ACP session abstraction
|
||||||
|
|
||||||
|
The session model is more uniform than most agent wrappers. It includes:
|
||||||
|
|
||||||
|
- capabilities
|
||||||
|
- mode/config options
|
||||||
|
- permission requests
|
||||||
|
- sequenced session events
|
||||||
|
- JSON-RPC transport through ACP adapters
|
||||||
|
|
||||||
|
This is directly relevant to Paperclip because our adapter layer still normalizes each CLI agent in a fairly bespoke way.
|
||||||
|
|
||||||
|
## Paperclip anchor points
|
||||||
|
|
||||||
|
The most relevant current Paperclip surfaces for any future `agent-os` integration are:
|
||||||
|
|
||||||
|
- `packages/adapter-utils/src/types.ts`
|
||||||
|
- shared adapter contract, session metadata, runtime service reporting, environment tests, and optional `detectModel()`
|
||||||
|
- `server/src/services/heartbeat.ts`
|
||||||
|
- heartbeat execution, adapter invocation, cost capture, workspace realization, and issue-comment summaries
|
||||||
|
- `server/src/services/execution-workspaces.ts`
|
||||||
|
- execution workspace lifecycle and git readiness/cleanup logic
|
||||||
|
- `server/src/services/plugin-loader.ts`
|
||||||
|
- dynamic plugin activation, host capability boundaries, and runtime extension loading
|
||||||
|
- local adapters such as `packages/adapters/codex-local/src/server/execute.ts` and peers
|
||||||
|
- current host-CLI execution model that an `agent-os` runtime experiment would complement or replace for selected agents
|
||||||
|
|
||||||
|
## What Paperclip can learn from it
|
||||||
|
|
||||||
|
### 1. A safer local execution substrate
|
||||||
|
|
||||||
|
Paperclip's local adapters currently run host CLIs in managed workspaces and rely on adapter-specific behavior plus process-level controls. That is pragmatic, but weakly isolated.
|
||||||
|
|
||||||
|
`agent-os` shows a path toward:
|
||||||
|
|
||||||
|
- running local agent tooling in a constrained runtime
|
||||||
|
- applying explicit network/filesystem/env policies
|
||||||
|
- reducing accidental host leakage
|
||||||
|
- making adapter behavior more portable across machines
|
||||||
|
|
||||||
|
Best use in Paperclip:
|
||||||
|
|
||||||
|
- as an optional runtime beneath local adapters
|
||||||
|
- or as a new adapter family for agents that can run inside ACP-compatible `agent-os` sessions
|
||||||
|
|
||||||
|
This fits Paperclip because it improves execution safety without changing the control-plane model.
|
||||||
|
|
||||||
|
### 2. Snapshotted execution roots instead of only mutable workspaces
|
||||||
|
|
||||||
|
Paperclip already has strong execution-workspace concepts, but they are repo/worktree-centric. `agent-os` adds a stronger "start from known lower layers, write into a disposable upper layer" model.
|
||||||
|
|
||||||
|
That could improve:
|
||||||
|
|
||||||
|
- reproducible issue starts
|
||||||
|
- disposable task sandboxes
|
||||||
|
- faster reset/cleanup
|
||||||
|
- "resume from snapshot" behavior for recurring routines
|
||||||
|
- safe preview environments for risky agent operations
|
||||||
|
|
||||||
|
This is especially interesting for tasks that do not need a full git worktree.
|
||||||
|
|
||||||
|
### 3. A capability vocabulary for runtime governance
|
||||||
|
|
||||||
|
Paperclip has governance at the company/task level:
|
||||||
|
|
||||||
|
- approvals
|
||||||
|
- budgets
|
||||||
|
- activity logs
|
||||||
|
- actor permissions
|
||||||
|
- company scoping
|
||||||
|
|
||||||
|
It has less structure at the runtime capability level. `agent-os` offers a clear vocabulary that Paperclip could adopt even without adopting the runtime itself:
|
||||||
|
|
||||||
|
- `fs.read`, `fs.write`, `fs.mount_sensitive`
|
||||||
|
- `network.fetch`, `network.http`, `network.listen`, `network.dns`
|
||||||
|
- child process execution
|
||||||
|
- env access
|
||||||
|
|
||||||
|
That vocabulary would improve:
|
||||||
|
|
||||||
|
- adapter configuration schemas
|
||||||
|
- policy UIs
|
||||||
|
- execution review surfaces
|
||||||
|
- future approval gates for governed actions
|
||||||
|
|
||||||
|
### 4. Typed host tools instead of shelling out for everything
|
||||||
|
|
||||||
|
Paperclip's plugin system and adapters already have the beginnings of a controlled extension surface. `agent-os` reinforces the value of exposing capabilities as typed tools rather than raw shell access.
|
||||||
|
|
||||||
|
Concrete Paperclip uses:
|
||||||
|
|
||||||
|
- board-approved toolkits for sensitive operations
|
||||||
|
- company-scoped service tools
|
||||||
|
- plugin-defined tools with explicit schemas
|
||||||
|
- safer execution for common actions like git metadata inspection, preview lookups, deployment status checks, or document generation
|
||||||
|
|
||||||
|
This aligns well with Paperclip's governance story.
|
||||||
|
|
||||||
|
### 5. Better adapter normalization around sessions and capabilities
|
||||||
|
|
||||||
|
Paperclip's adapter contract already supports execution results, session params, environment tests, skill syncing, quota windows, and optional `detectModel()`. But much of the per-agent behavior is still adapter-specific.
|
||||||
|
|
||||||
|
`agent-os` suggests a cleaner normalization target:
|
||||||
|
|
||||||
|
- a standard capability map
|
||||||
|
- a consistent event stream model
|
||||||
|
- explicit mode/config surfaces
|
||||||
|
- explicit permission request semantics
|
||||||
|
|
||||||
|
Paperclip does not need ACP everywhere, but it would benefit from a more formal internal session capability model inspired by this.
|
||||||
|
|
||||||
|
### 6. On-demand heavy sandbox escalation
|
||||||
|
|
||||||
|
One of the best architectural choices in `agent-os` is that it does not pretend every workload fits the lightweight runtime. It has a sandbox extension for workloads that need a fuller environment.
|
||||||
|
|
||||||
|
Paperclip can adopt that philosophy directly:
|
||||||
|
|
||||||
|
- lightweight execution by default
|
||||||
|
- escalate to full worktree / container / remote sandbox only when needed
|
||||||
|
- keep the escalation explicit in the issue/run model
|
||||||
|
|
||||||
|
That is better than forcing all tasks into the heaviest environment up front.
|
||||||
|
|
||||||
|
## What does not fit Paperclip well
|
||||||
|
|
||||||
|
### 1. Its built-in orchestration primitives overlap the wrong layer
|
||||||
|
|
||||||
|
`agent-os` includes cron/session/workflow style primitives inside the runtime package. Paperclip already has higher-level orchestration concepts:
|
||||||
|
|
||||||
|
- issues/comments
|
||||||
|
- heartbeat runs
|
||||||
|
- approvals
|
||||||
|
- company/org structure
|
||||||
|
- execution workspaces
|
||||||
|
- budget enforcement
|
||||||
|
|
||||||
|
If Paperclip copied `agent-os` cron/workflow/queue ideas directly into core, we would likely duplicate orchestration across two layers. That would blur ownership and make debugging harder.
|
||||||
|
|
||||||
|
Paperclip should keep orchestration authoritative at the control-plane layer.
|
||||||
|
|
||||||
|
### 2. It is not company-scoped or governance-native
|
||||||
|
|
||||||
|
`agent-os` is runtime-first, not company-first. It has no native concepts for:
|
||||||
|
|
||||||
|
- company boundaries
|
||||||
|
- board/operator actor types
|
||||||
|
- audit logs for business actions
|
||||||
|
- issue hierarchy
|
||||||
|
- approval routing
|
||||||
|
- budget hard-stop behavior
|
||||||
|
|
||||||
|
Those are Paperclip's differentiators. They should not be displaced by runtime abstractions.
|
||||||
|
|
||||||
|
### 3. It introduces meaningful implementation complexity
|
||||||
|
|
||||||
|
Adopting `agent-os` deeply would add:
|
||||||
|
|
||||||
|
- Rust build/runtime complexity
|
||||||
|
- sidecar lifecycle management
|
||||||
|
- new failure modes across JS/Rust boundaries
|
||||||
|
- more packaging and platform compatibility work
|
||||||
|
- another abstraction layer for debugging already-complex local adapters
|
||||||
|
|
||||||
|
This is justified only if we want stronger local isolation or portability. It is not justified as a general refactor.
|
||||||
|
|
||||||
|
### 4. Its security model is not a drop-in governance solution
|
||||||
|
|
||||||
|
The permission model is good, but it is low-level. Paperclip would still need to answer:
|
||||||
|
|
||||||
|
- who can authorize a capability
|
||||||
|
- how approval decisions are logged
|
||||||
|
- how policies are scoped by company/project/issue/agent
|
||||||
|
- how runtime permissions interact with budgets and task status
|
||||||
|
|
||||||
|
In other words, `agent-os` can supply enforcement primitives, not the control policy system itself.
|
||||||
|
|
||||||
|
### 5. The agent compatibility story is still selective
|
||||||
|
|
||||||
|
The repo is explicit that some runtimes are planned, partial, or still being adapted. In practice this means:
|
||||||
|
|
||||||
|
- good ideas for ACP-native or compatible agents
|
||||||
|
- less certainty for every CLI agent we support today
|
||||||
|
- real integration work for Codex/Cursor/Gemini-style Paperclip adapters
|
||||||
|
|
||||||
|
So the main near-term value is not universal replacement. It is selective use where compatibility is strong.
|
||||||
|
|
||||||
|
## Concrete recommendations for Paperclip
|
||||||
|
|
||||||
|
### Recommendation A: prototype an optional `agentos_local` adapter
|
||||||
|
|
||||||
|
This is the highest-value experiment.
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
|
||||||
|
- run one supported agent type inside `agent-os`
|
||||||
|
- keep Paperclip heartbeat/task/workspace/budget logic unchanged
|
||||||
|
- evaluate startup time, isolation, transcript quality, and operational complexity
|
||||||
|
|
||||||
|
Good first target:
|
||||||
|
|
||||||
|
- `pi_local` or `opencode_local`
|
||||||
|
|
||||||
|
Why not start with Codex:
|
||||||
|
|
||||||
|
- Paperclip's Codex adapter is already important and carries repo-specific behavior
|
||||||
|
- `agent-os`'s Codex story is present in the registry/docs, but the safest path is to validate the runtime on a less central adapter first
|
||||||
|
|
||||||
|
Success criteria:
|
||||||
|
|
||||||
|
- heartbeat can invoke the adapter reliably
|
||||||
|
- session resume works across heartbeats
|
||||||
|
- Paperclip still records logs, summaries, cost metadata, and issue comments normally
|
||||||
|
- runtime permissions can be configured without breaking common tasks
|
||||||
|
|
||||||
|
### Recommendation B: adopt capability vocabulary into adapter configs
|
||||||
|
|
||||||
|
Even without using `agent-os`, Paperclip should consider standardizing adapter/runtime permissions around a vocabulary like:
|
||||||
|
|
||||||
|
- filesystem
|
||||||
|
- network
|
||||||
|
- subprocess/tool execution
|
||||||
|
- environment access
|
||||||
|
|
||||||
|
This would improve:
|
||||||
|
|
||||||
|
- schema-driven adapter UIs
|
||||||
|
- future approvals
|
||||||
|
- observability
|
||||||
|
- policy portability across adapters
|
||||||
|
|
||||||
|
### Recommendation C: explore snapshot-backed execution workspaces
|
||||||
|
|
||||||
|
Paperclip should evaluate whether some execution workspaces can be backed by:
|
||||||
|
|
||||||
|
- a reusable lower snapshot
|
||||||
|
- a disposable upper layer
|
||||||
|
- optional mounts for project data or artifacts
|
||||||
|
|
||||||
|
This is most valuable for:
|
||||||
|
|
||||||
|
- non-repo tasks
|
||||||
|
- repeatable routines
|
||||||
|
- preview/test environments
|
||||||
|
- isolation-heavy local execution
|
||||||
|
|
||||||
|
It is less urgent for full repo editing flows that already benefit from git worktrees.
|
||||||
|
|
||||||
|
### Recommendation D: strengthen typed tool surfaces
|
||||||
|
|
||||||
|
Paperclip plugins and adapters should continue moving toward explicit typed tools over ad hoc shell access. `agent-os` confirms that this is the right direction.
|
||||||
|
|
||||||
|
This is a good fit for:
|
||||||
|
|
||||||
|
- plugin tools
|
||||||
|
- workspace runtime services
|
||||||
|
- governed operations that need approval or auditability
|
||||||
|
|
||||||
|
### Recommendation E: do not import runtime-level workflows into Paperclip core
|
||||||
|
|
||||||
|
Paperclip should not copy `agent-os` cron/workflow/queue concepts into core orchestration yet.
|
||||||
|
|
||||||
|
If we want them later, they must map cleanly onto:
|
||||||
|
|
||||||
|
- issues
|
||||||
|
- comments
|
||||||
|
- heartbeats
|
||||||
|
- approvals
|
||||||
|
- budgets
|
||||||
|
- activity logs
|
||||||
|
|
||||||
|
Without that mapping, they would create a second orchestration system inside the product.
|
||||||
|
|
||||||
|
## A practical integration map
|
||||||
|
|
||||||
|
### Best near-term fits
|
||||||
|
|
||||||
|
- optional local adapter runtime
|
||||||
|
- runtime capability schema
|
||||||
|
- typed host-tool ideas for plugins/adapters
|
||||||
|
- snapshot ideas for disposable execution roots
|
||||||
|
|
||||||
|
### Medium-term fits
|
||||||
|
|
||||||
|
- stronger session capability normalization across adapters
|
||||||
|
- policy-aware runtime permission UI
|
||||||
|
- selective ACP-inspired event normalization
|
||||||
|
|
||||||
|
### Poor fits right now
|
||||||
|
|
||||||
|
- moving Paperclip orchestration into agent-os workflows
|
||||||
|
- replacing company/task/governance models with runtime constructs
|
||||||
|
- making Rust sidecars a mandatory dependency for all local execution
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
`agent-os` is useful to Paperclip as an execution technology reference, not as a product model.
|
||||||
|
|
||||||
|
Paperclip should treat it the same way it treats sandboxes or agent CLIs:
|
||||||
|
|
||||||
|
- execution substrate underneath the control plane
|
||||||
|
- optional where the tradeoff is worth it
|
||||||
|
- never the source of truth for company/task/governance state
|
||||||
|
|
||||||
|
If we do one thing from this report, it should be a narrowly scoped `agentos_local` experiment plus a design pass on capability-based runtime permissions. Those two ideas have the best upside and the lowest architectural risk.
|
||||||
38
packages/adapter-utils/src/server-utils.test.ts
Normal file
38
packages/adapter-utils/src/server-utils.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { runChildProcess } from "./server-utils.js";
|
||||||
|
|
||||||
|
describe("runChildProcess", () => {
|
||||||
|
it("waits for onSpawn before sending stdin to the child", async () => {
|
||||||
|
const spawnDelayMs = 150;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let onSpawnCompletedAt = 0;
|
||||||
|
|
||||||
|
const result = await runChildProcess(
|
||||||
|
randomUUID(),
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
"-e",
|
||||||
|
"let data='';process.stdin.setEncoding('utf8');process.stdin.on('data',chunk=>data+=chunk);process.stdin.on('end',()=>process.stdout.write(data));",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: {},
|
||||||
|
stdin: "hello from stdin",
|
||||||
|
timeoutSec: 5,
|
||||||
|
graceSec: 1,
|
||||||
|
onLog: async () => {},
|
||||||
|
onSpawn: async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, spawnDelayMs));
|
||||||
|
onSpawnCompletedAt = Date.now();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const finishedAt = Date.now();
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.stdout).toBe("hello from stdin");
|
||||||
|
expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs);
|
||||||
|
expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -201,6 +201,22 @@ type PaperclipWakeIssue = {
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PaperclipWakeExecutionPrincipal = {
|
||||||
|
type: "agent" | "user" | null;
|
||||||
|
agentId: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaperclipWakeExecutionStage = {
|
||||||
|
wakeRole: "reviewer" | "approver" | "executor" | null;
|
||||||
|
stageId: string | null;
|
||||||
|
stageType: string | null;
|
||||||
|
currentParticipant: PaperclipWakeExecutionPrincipal | null;
|
||||||
|
returnAssignee: PaperclipWakeExecutionPrincipal | null;
|
||||||
|
lastDecisionOutcome: string | null;
|
||||||
|
allowedActions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type PaperclipWakeComment = {
|
type PaperclipWakeComment = {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
issueId: string | null;
|
issueId: string | null;
|
||||||
|
|
@ -214,6 +230,7 @@ type PaperclipWakeComment = {
|
||||||
type PaperclipWakePayload = {
|
type PaperclipWakePayload = {
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
issue: PaperclipWakeIssue | null;
|
issue: PaperclipWakeIssue | null;
|
||||||
|
executionStage: PaperclipWakeExecutionStage | null;
|
||||||
commentIds: string[];
|
commentIds: string[];
|
||||||
latestCommentId: string | null;
|
latestCommentId: string | null;
|
||||||
comments: PaperclipWakeComment[];
|
comments: PaperclipWakeComment[];
|
||||||
|
|
@ -257,6 +274,50 @@ function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | n
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
|
||||||
|
const principal = parseObject(value);
|
||||||
|
const typeRaw = asString(principal.type, "").trim().toLowerCase();
|
||||||
|
if (typeRaw !== "agent" && typeRaw !== "user") return null;
|
||||||
|
return {
|
||||||
|
type: typeRaw,
|
||||||
|
agentId: asString(principal.agentId, "").trim() || null,
|
||||||
|
userId: asString(principal.userId, "").trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePaperclipWakeExecutionStage(value: unknown): PaperclipWakeExecutionStage | null {
|
||||||
|
const stage = parseObject(value);
|
||||||
|
const wakeRoleRaw = asString(stage.wakeRole, "").trim().toLowerCase();
|
||||||
|
const wakeRole =
|
||||||
|
wakeRoleRaw === "reviewer" || wakeRoleRaw === "approver" || wakeRoleRaw === "executor"
|
||||||
|
? wakeRoleRaw
|
||||||
|
: null;
|
||||||
|
const allowedActions = Array.isArray(stage.allowedActions)
|
||||||
|
? stage.allowedActions
|
||||||
|
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
: [];
|
||||||
|
const currentParticipant = normalizePaperclipWakeExecutionPrincipal(stage.currentParticipant);
|
||||||
|
const returnAssignee = normalizePaperclipWakeExecutionPrincipal(stage.returnAssignee);
|
||||||
|
const stageId = asString(stage.stageId, "").trim() || null;
|
||||||
|
const stageType = asString(stage.stageType, "").trim() || null;
|
||||||
|
const lastDecisionOutcome = asString(stage.lastDecisionOutcome, "").trim() || null;
|
||||||
|
|
||||||
|
if (!wakeRole && !stageId && !stageType && !currentParticipant && !returnAssignee && !lastDecisionOutcome && allowedActions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wakeRole,
|
||||||
|
stageId,
|
||||||
|
stageType,
|
||||||
|
currentParticipant,
|
||||||
|
returnAssignee,
|
||||||
|
lastDecisionOutcome,
|
||||||
|
allowedActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null {
|
export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null {
|
||||||
const payload = parseObject(value);
|
const payload = parseObject(value);
|
||||||
const comments = Array.isArray(payload.comments)
|
const comments = Array.isArray(payload.comments)
|
||||||
|
|
@ -270,12 +331,16 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
: [];
|
: [];
|
||||||
|
const executionStage = normalizePaperclipWakeExecutionStage(payload.executionStage);
|
||||||
|
|
||||||
if (comments.length === 0 && commentIds.length === 0) return null;
|
if (comments.length === 0 && commentIds.length === 0 && !executionStage && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reason: asString(payload.reason, "").trim() || null,
|
reason: asString(payload.reason, "").trim() || null,
|
||||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||||
|
executionStage,
|
||||||
commentIds,
|
commentIds,
|
||||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||||
comments,
|
comments,
|
||||||
|
|
@ -300,6 +365,12 @@ export function renderPaperclipWakePrompt(
|
||||||
const normalized = normalizePaperclipWakePayload(value);
|
const normalized = normalizePaperclipWakePayload(value);
|
||||||
if (!normalized) return "";
|
if (!normalized) return "";
|
||||||
const resumedSession = options.resumedSession === true;
|
const resumedSession = options.resumedSession === true;
|
||||||
|
const executionStage = normalized.executionStage;
|
||||||
|
const principalLabel = (principal: PaperclipWakeExecutionPrincipal | null) => {
|
||||||
|
if (!principal || !principal.type) return "unknown";
|
||||||
|
if (principal.type === "agent") return principal.agentId ? `agent ${principal.agentId}` : "agent";
|
||||||
|
return principal.userId ? `user ${principal.userId}` : "user";
|
||||||
|
};
|
||||||
|
|
||||||
const lines = resumedSession
|
const lines = resumedSession
|
||||||
? [
|
? [
|
||||||
|
|
@ -342,7 +413,38 @@ export function renderPaperclipWakePrompt(
|
||||||
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("", "New comments in order:");
|
if (executionStage) {
|
||||||
|
lines.push(
|
||||||
|
`- execution wake role: ${executionStage.wakeRole ?? "unknown"}`,
|
||||||
|
`- execution stage: ${executionStage.stageType ?? "unknown"}`,
|
||||||
|
`- execution participant: ${principalLabel(executionStage.currentParticipant)}`,
|
||||||
|
`- execution return assignee: ${principalLabel(executionStage.returnAssignee)}`,
|
||||||
|
`- last decision outcome: ${executionStage.lastDecisionOutcome ?? "none"}`,
|
||||||
|
);
|
||||||
|
if (executionStage.allowedActions.length > 0) {
|
||||||
|
lines.push(`- allowed actions: ${executionStage.allowedActions.join(", ")}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
if (executionStage.wakeRole === "reviewer" || executionStage.wakeRole === "approver") {
|
||||||
|
lines.push(
|
||||||
|
`You are waking as the active ${executionStage.wakeRole} for this issue.`,
|
||||||
|
"Do not execute the task itself or continue executor work.",
|
||||||
|
"Review the issue and choose one of the allowed actions above.",
|
||||||
|
"If you request changes, the workflow routes back to the stored return assignee.",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
} else if (executionStage.wakeRole === "executor") {
|
||||||
|
lines.push(
|
||||||
|
"You are waking because changes were requested in the execution workflow.",
|
||||||
|
"Address the requested changes on this issue and resubmit when the work is ready.",
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.comments.length > 0) {
|
||||||
|
lines.push("New comments in order:");
|
||||||
|
}
|
||||||
|
|
||||||
for (const [index, comment] of normalized.comments.entries()) {
|
for (const [index, comment] of normalized.comments.entries()) {
|
||||||
const authorLabel = comment.authorId
|
const authorLabel = comment.authorId
|
||||||
|
|
@ -967,16 +1069,12 @@ export async function runChildProcess(
|
||||||
}) as ChildProcessWithEvents;
|
}) as ChildProcessWithEvents;
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
if (opts.stdin != null && child.stdin) {
|
const spawnPersistPromise =
|
||||||
child.stdin.write(opts.stdin);
|
typeof child.pid === "number" && child.pid > 0 && opts.onSpawn
|
||||||
child.stdin.end();
|
? opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
|
||||||
}
|
onLogError(err, runId, "failed to record child process metadata");
|
||||||
|
})
|
||||||
if (typeof child.pid === "number" && child.pid > 0 && opts.onSpawn) {
|
: Promise.resolve();
|
||||||
void opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => {
|
|
||||||
onLogError(err, runId, "failed to record child process metadata");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||||
|
|
||||||
|
|
@ -1014,6 +1112,15 @@ export async function runChildProcess(
|
||||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stdin = child.stdin;
|
||||||
|
if (opts.stdin != null && stdin) {
|
||||||
|
void spawnPersistPromise.finally(() => {
|
||||||
|
if (child.killed || stdin.destroyed) return;
|
||||||
|
stdin.write(opts.stdin as string);
|
||||||
|
stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
child.on("error", (err: Error) => {
|
child.on("error", (err: Error) => {
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) clearTimeout(timeout);
|
||||||
runningProcesses.delete(runId);
|
runningProcesses.delete(runId);
|
||||||
|
|
|
||||||
83
packages/adapters/codex-local/src/ui/parse-stdout.test.ts
Normal file
83
packages/adapters/codex-local/src/ui/parse-stdout.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseCodexStdoutLine } from "./parse-stdout.js";
|
||||||
|
|
||||||
|
describe("parseCodexStdoutLine", () => {
|
||||||
|
it("marks completed tool_use items as resolved tool results", () => {
|
||||||
|
const started = parseCodexStdoutLine(JSON.stringify({
|
||||||
|
type: "item.started",
|
||||||
|
item: {
|
||||||
|
id: "tool-1",
|
||||||
|
type: "tool_use",
|
||||||
|
name: "search",
|
||||||
|
input: { query: "paperclip" },
|
||||||
|
},
|
||||||
|
}), "2026-04-08T12:00:00.000Z");
|
||||||
|
|
||||||
|
const completed = parseCodexStdoutLine(JSON.stringify({
|
||||||
|
type: "item.completed",
|
||||||
|
item: {
|
||||||
|
id: "tool-1",
|
||||||
|
type: "tool_use",
|
||||||
|
name: "search",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
}), "2026-04-08T12:00:01.000Z");
|
||||||
|
|
||||||
|
expect(started).toEqual([{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-04-08T12:00:00.000Z",
|
||||||
|
name: "search",
|
||||||
|
toolUseId: "tool-1",
|
||||||
|
input: { query: "paperclip" },
|
||||||
|
}]);
|
||||||
|
expect(completed).toEqual([{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-04-08T12:00:01.000Z",
|
||||||
|
toolUseId: "tool-1",
|
||||||
|
content: "search completed",
|
||||||
|
isError: false,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps explicit tool_result payloads authoritative after tool_use completion", () => {
|
||||||
|
const completed = parseCodexStdoutLine(JSON.stringify({
|
||||||
|
type: "item.completed",
|
||||||
|
item: {
|
||||||
|
id: "tool-2",
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "tool-1",
|
||||||
|
content: "final payload",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
}), "2026-04-08T12:00:02.000Z");
|
||||||
|
|
||||||
|
expect(completed).toEqual([{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-04-08T12:00:02.000Z",
|
||||||
|
toolUseId: "tool-1",
|
||||||
|
content: "final payload",
|
||||||
|
isError: false,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks failed completed tool_use items as error results", () => {
|
||||||
|
const completed = parseCodexStdoutLine(JSON.stringify({
|
||||||
|
type: "item.completed",
|
||||||
|
item: {
|
||||||
|
id: "tool-3",
|
||||||
|
type: "tool_use",
|
||||||
|
name: "write_file",
|
||||||
|
status: "error",
|
||||||
|
error: { message: "permission denied" },
|
||||||
|
},
|
||||||
|
}), "2026-04-08T12:00:03.000Z");
|
||||||
|
|
||||||
|
expect(completed).toEqual([{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-04-08T12:00:03.000Z",
|
||||||
|
toolUseId: "tool-3",
|
||||||
|
content: "permission denied",
|
||||||
|
isError: true,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -118,6 +118,52 @@ function parseFileChangeItem(item: Record<string, unknown>, ts: string): Transcr
|
||||||
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
|
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseToolUseItem(
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
ts: string,
|
||||||
|
phase: "started" | "completed",
|
||||||
|
): TranscriptEntry[] {
|
||||||
|
const name = asString(item.name, "unknown");
|
||||||
|
const toolUseId = asString(item.id, name || "tool_use");
|
||||||
|
|
||||||
|
if (phase === "started") {
|
||||||
|
return [{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name,
|
||||||
|
toolUseId,
|
||||||
|
input: item.input ?? {},
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = asString(item.status);
|
||||||
|
const isError =
|
||||||
|
item.is_error === true ||
|
||||||
|
status === "failed" ||
|
||||||
|
status === "errored" ||
|
||||||
|
status === "error" ||
|
||||||
|
status === "cancelled";
|
||||||
|
const rawContent =
|
||||||
|
item.content ??
|
||||||
|
item.output ??
|
||||||
|
item.result ??
|
||||||
|
item.error ??
|
||||||
|
item.message;
|
||||||
|
const content =
|
||||||
|
asString(rawContent) ||
|
||||||
|
errorText(rawContent) ||
|
||||||
|
stringifyUnknown(rawContent) ||
|
||||||
|
`${name} ${isError ? "failed" : "completed"}`;
|
||||||
|
|
||||||
|
return [{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts,
|
||||||
|
toolUseId,
|
||||||
|
content,
|
||||||
|
isError,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
function parseCodexItem(
|
function parseCodexItem(
|
||||||
item: Record<string, unknown>,
|
item: Record<string, unknown>,
|
||||||
ts: string,
|
ts: string,
|
||||||
|
|
@ -146,13 +192,7 @@ function parseCodexItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemType === "tool_use") {
|
if (itemType === "tool_use") {
|
||||||
return [{
|
return parseToolUseItem(item, ts, phase);
|
||||||
kind: "tool_call",
|
|
||||||
ts,
|
|
||||||
name: asString(item.name, "unknown"),
|
|
||||||
toolUseId: asString(item.id),
|
|
||||||
input: item.input ?? {},
|
|
||||||
}];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemType === "tool_result" && phase === "completed") {
|
if (itemType === "tool_result" && phase === "completed") {
|
||||||
|
|
|
||||||
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal file
18
packages/db/src/migrations/0053_sharp_wild_child.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "inbox_dismissals" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"company_id" uuid NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"item_key" text NOT NULL,
|
||||||
|
"dismissed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "inbox_dismissals" ADD CONSTRAINT "inbox_dismissals_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_user_idx" ON "inbox_dismissals" USING btree ("company_id","user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "inbox_dismissals_company_item_idx" ON "inbox_dismissals" USING btree ("company_id","item_key");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "inbox_dismissals_company_user_item_idx" ON "inbox_dismissals" USING btree ("company_id","user_id","item_key");
|
||||||
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
12979
packages/db/src/migrations/meta/0053_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -372,6 +372,13 @@
|
||||||
"when": 1775571715162,
|
"when": 1775571715162,
|
||||||
"tag": "0052_mushy_trauma",
|
"tag": "0052_mushy_trauma",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775604018515,
|
||||||
|
"tag": "0053_sharp_wild_child",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
packages/db/src/schema/inbox_dismissals.ts
Normal file
24
packages/db/src/schema/inbox_dismissals.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
import { companies } from "./companies.js";
|
||||||
|
|
||||||
|
export const inboxDismissals = pgTable(
|
||||||
|
"inbox_dismissals",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
itemKey: text("item_key").notNull(),
|
||||||
|
dismissedAt: timestamp("dismissed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
companyUserIdx: index("inbox_dismissals_company_user_idx").on(table.companyId, table.userId),
|
||||||
|
companyItemIdx: index("inbox_dismissals_company_item_idx").on(table.companyId, table.itemKey),
|
||||||
|
companyUserItemUnique: uniqueIndex("inbox_dismissals_company_user_item_idx").on(
|
||||||
|
table.companyId,
|
||||||
|
table.userId,
|
||||||
|
table.itemKey,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -34,6 +34,7 @@ export { issueApprovals } from "./issue_approvals.js";
|
||||||
export { issueComments } from "./issue_comments.js";
|
export { issueComments } from "./issue_comments.js";
|
||||||
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
export { issueExecutionDecisions } from "./issue_execution_decisions.js";
|
||||||
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
export { issueInboxArchives } from "./issue_inbox_archives.js";
|
||||||
|
export { inboxDismissals } from "./inbox_dismissals.js";
|
||||||
export { feedbackVotes } from "./feedback_votes.js";
|
export { feedbackVotes } from "./feedback_votes.js";
|
||||||
export { feedbackExports } from "./feedback_exports.js";
|
export { feedbackExports } from "./feedback_exports.js";
|
||||||
export { issueReadStates } from "./issue_read_states.js";
|
export { issueReadStates } from "./issue_read_states.js";
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,7 @@ export type {
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
SidebarBadges,
|
SidebarBadges,
|
||||||
|
InboxDismissal,
|
||||||
CompanyMembership,
|
CompanyMembership,
|
||||||
PrincipalPermissionGrant,
|
PrincipalPermissionGrant,
|
||||||
Invite,
|
Invite,
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,18 @@ describe("routine variable helpers", () => {
|
||||||
).toEqual(["repo", "priority"]);
|
).toEqual(["repo", "priority"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("deduplicates placeholder names across the routine title and description", () => {
|
||||||
|
expect(
|
||||||
|
extractRoutineVariableNames([
|
||||||
|
"Triage {{repo}}",
|
||||||
|
"Review {{repo}} for {{priority}} bugs",
|
||||||
|
]),
|
||||||
|
).toEqual(["repo", "priority"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves existing metadata when syncing variables from a template", () => {
|
it("preserves existing metadata when syncing variables from a template", () => {
|
||||||
expect(
|
expect(
|
||||||
syncRoutineVariablesWithTemplate("Review {{repo}} and {{priority}}", [
|
syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [
|
||||||
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
|
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
|
||||||
]),
|
]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
import type { RoutineVariable } from "./types/routine.js";
|
import type { RoutineVariable } from "./types/routine.js";
|
||||||
|
|
||||||
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g;
|
||||||
|
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
|
||||||
|
|
||||||
export function isValidRoutineVariableName(name: string): boolean {
|
export function isValidRoutineVariableName(name: string): boolean {
|
||||||
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractRoutineVariableNames(template: string | null | undefined): string[] {
|
function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
|
||||||
if (!template) return [];
|
const templates = Array.isArray(input) ? input : [input];
|
||||||
|
return templates.filter((template): template is string => typeof template === "string" && template.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractRoutineVariableNames(template: RoutineTemplateInput): string[] {
|
||||||
const found = new Set<string>();
|
const found = new Set<string>();
|
||||||
for (const match of template.matchAll(ROUTINE_VARIABLE_MATCHER)) {
|
for (const source of normalizeRoutineTemplateInput(template)) {
|
||||||
const name = match[1];
|
for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) {
|
||||||
if (name && !found.has(name)) {
|
const name = match[1];
|
||||||
found.add(name);
|
if (name && !found.has(name)) {
|
||||||
|
found.add(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...found];
|
return [...found];
|
||||||
|
|
@ -30,7 +37,7 @@ function defaultRoutineVariable(name: string): RoutineVariable {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncRoutineVariablesWithTemplate(
|
export function syncRoutineVariablesWithTemplate(
|
||||||
template: string | null | undefined,
|
template: RoutineTemplateInput,
|
||||||
existing: RoutineVariable[] | null | undefined,
|
existing: RoutineVariable[] | null | undefined,
|
||||||
): RoutineVariable[] {
|
): RoutineVariable[] {
|
||||||
const names = extractRoutineVariableNames(template);
|
const names = extractRoutineVariableNames(template);
|
||||||
|
|
|
||||||
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
9
packages/shared/src/types/inbox-dismissal.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface InboxDismissal {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
userId: string;
|
||||||
|
itemKey: string;
|
||||||
|
dismissedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -164,6 +164,7 @@ export type { LiveEvent } from "./live.js";
|
||||||
export type { DashboardSummary } from "./dashboard.js";
|
export type { DashboardSummary } from "./dashboard.js";
|
||||||
export type { ActivityEvent } from "./activity.js";
|
export type { ActivityEvent } from "./activity.js";
|
||||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||||
|
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||||
export type {
|
export type {
|
||||||
CompanyMembership,
|
CompanyMembership,
|
||||||
PrincipalPermissionGrant,
|
PrincipalPermissionGrant,
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
#!/usr/bin/env -S node --import tsx
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { repoRoot } from "./dev-service-profile.ts";
|
|
||||||
|
|
||||||
type WorkspaceLinkMismatch = {
|
|
||||||
workspaceDir: string;
|
|
||||||
packageName: string;
|
|
||||||
expectedPath: string;
|
|
||||||
actualPath: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function readJsonFile(filePath: string): Record<string, unknown> {
|
|
||||||
return JSON.parse(readFileSync(filePath, "utf8")) as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
|
||||||
const packagePaths = new Map<string, string>();
|
|
||||||
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
|
||||||
|
|
||||||
function visit(dirPath: string) {
|
|
||||||
const packageJsonPath = path.join(dirPath, "package.json");
|
|
||||||
if (existsSync(packageJsonPath)) {
|
|
||||||
const packageJson = readJsonFile(packageJsonPath);
|
|
||||||
if (typeof packageJson.name === "string" && packageJson.name.length > 0) {
|
|
||||||
packagePaths.set(packageJson.name, dirPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
|
|
||||||
if (!entry.isDirectory()) continue;
|
|
||||||
if (ignoredDirNames.has(entry.name)) continue;
|
|
||||||
visit(path.join(dirPath, entry.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visit(path.join(rootDir, "packages"));
|
|
||||||
visit(path.join(rootDir, "server"));
|
|
||||||
visit(path.join(rootDir, "ui"));
|
|
||||||
visit(path.join(rootDir, "cli"));
|
|
||||||
|
|
||||||
return packagePaths;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot);
|
|
||||||
|
|
||||||
function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] {
|
|
||||||
const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json"));
|
|
||||||
const dependencies = {
|
|
||||||
...(packageJson.dependencies as Record<string, unknown> | undefined),
|
|
||||||
...(packageJson.devDependencies as Record<string, unknown> | undefined),
|
|
||||||
};
|
|
||||||
const mismatches: WorkspaceLinkMismatch[] = [];
|
|
||||||
|
|
||||||
for (const [packageName, version] of Object.entries(dependencies)) {
|
|
||||||
if (typeof version !== "string" || !version.startsWith("workspace:")) continue;
|
|
||||||
|
|
||||||
const expectedPath = workspacePackagePaths.get(packageName);
|
|
||||||
if (!expectedPath) continue;
|
|
||||||
|
|
||||||
const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/"));
|
|
||||||
const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null;
|
|
||||||
if (actualPath === path.resolve(expectedPath)) continue;
|
|
||||||
|
|
||||||
mismatches.push({
|
|
||||||
workspaceDir,
|
|
||||||
packageName,
|
|
||||||
expectedPath: path.resolve(expectedPath),
|
|
||||||
actualPath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return mismatches;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureWorkspaceLinksCurrent(workspaceDir: string) {
|
|
||||||
const mismatches = findWorkspaceLinkMismatches(workspaceDir);
|
|
||||||
if (mismatches.length === 0) return;
|
|
||||||
|
|
||||||
console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; relinking dependencies...`);
|
|
||||||
for (const mismatch of mismatches) {
|
|
||||||
console.log(
|
|
||||||
`[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const mismatch of mismatches) {
|
|
||||||
const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/"));
|
|
||||||
await fs.mkdir(path.dirname(linkPath), { recursive: true });
|
|
||||||
await fs.rm(linkPath, { recursive: true, force: true });
|
|
||||||
await fs.symlink(mismatch.expectedPath, linkPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir);
|
|
||||||
if (remainingMismatches.length === 0) return;
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const workspaceDir of ["server", "ui"]) {
|
|
||||||
await ensureWorkspaceLinksCurrent(workspaceDir);
|
|
||||||
}
|
|
||||||
65
scripts/kill-agent-browsers.sh
Executable file
65
scripts/kill-agent-browsers.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Kill all "Google Chrome for Testing" processes (agent headless browsers).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/kill-agent-browsers.sh # kill all
|
||||||
|
# scripts/kill-agent-browsers.sh --dry # preview what would be killed
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then
|
||||||
|
DRY_RUN=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
pids=()
|
||||||
|
lines=()
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
pid=$(echo "$line" | awk '{print $2}')
|
||||||
|
pids+=("$pid")
|
||||||
|
lines+=("$line")
|
||||||
|
done < <(ps aux | grep 'Google Chrome for Testing' | grep -v grep || true)
|
||||||
|
|
||||||
|
if [[ ${#pids[@]} -eq 0 ]]; then
|
||||||
|
echo "No Google Chrome for Testing processes found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found ${#pids[@]} Google Chrome for Testing process(es):"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for i in "${!pids[@]}"; do
|
||||||
|
line="${lines[$i]}"
|
||||||
|
pid=$(echo "$line" | awk '{print $2}')
|
||||||
|
start=$(echo "$line" | awk '{print $9}')
|
||||||
|
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||||
|
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||||
|
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
|
echo "Dry run — re-run without --dry to kill these processes."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Sending SIGTERM..."
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||||
|
done
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo " $pid still alive, sending SIGKILL..."
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
|
|
@ -24,6 +24,8 @@ node_lines=()
|
||||||
pg_pids=()
|
pg_pids=()
|
||||||
pg_pidfiles=()
|
pg_pidfiles=()
|
||||||
pg_data_dirs=()
|
pg_data_dirs=()
|
||||||
|
browser_pids=()
|
||||||
|
browser_lines=()
|
||||||
|
|
||||||
is_pid_running() {
|
is_pid_running() {
|
||||||
local pid="$1"
|
local pid="$1"
|
||||||
|
|
@ -87,6 +89,14 @@ while IFS= read -r line; do
|
||||||
node_lines+=("$line")
|
node_lines+=("$line")
|
||||||
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
|
done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true)
|
||||||
|
|
||||||
|
# --- Agent browser processes (headless Chrome from ~/.agent-browser) ---
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
pid=$(echo "$line" | awk '{print $2}')
|
||||||
|
browser_pids+=("$pid")
|
||||||
|
browser_lines+=("$line")
|
||||||
|
done < <(ps aux | grep -E 'agent-browser/browsers/chrome-.*/Google Chrome for Testing' | grep -v grep || true)
|
||||||
|
|
||||||
candidate_pidfiles=()
|
candidate_pidfiles=()
|
||||||
candidate_pidfiles+=(
|
candidate_pidfiles+=(
|
||||||
"$HOME"/.paperclip/instances/*/db/postmaster.pid
|
"$HOME"/.paperclip/instances/*/db/postmaster.pid
|
||||||
|
|
@ -107,7 +117,7 @@ for pidfile in "${candidate_pidfiles[@]:-}"; do
|
||||||
append_postgres_from_pidfile "$pidfile"
|
append_postgres_from_pidfile "$pidfile"
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 ]]; then
|
if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 && ${#browser_pids[@]} -eq 0 ]]; then
|
||||||
echo "No Paperclip dev processes found."
|
echo "No Paperclip dev processes found."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -144,6 +154,22 @@ if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||||
|
echo "Found ${#browser_pids[@]} agent browser process(es):"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for i in "${!browser_pids[@]:-}"; do
|
||||||
|
line="${browser_lines[$i]}"
|
||||||
|
pid=$(echo "$line" | awk '{print $2}')
|
||||||
|
start=$(echo "$line" | awk '{print $9}')
|
||||||
|
cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}')
|
||||||
|
cmd=$(echo "$cmd" | sed "s|$HOME/||g")
|
||||||
|
printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$DRY_RUN" == true ]]; then
|
if [[ "$DRY_RUN" == true ]]; then
|
||||||
echo "Dry run — re-run without --dry to kill these processes."
|
echo "Dry run — re-run without --dry to kill these processes."
|
||||||
exit 0
|
exit 0
|
||||||
|
|
@ -158,6 +184,13 @@ if [[ ${#node_pids[@]} -gt 0 ]]; then
|
||||||
sleep 2
|
sleep 2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||||
|
echo "Sending SIGTERM to agent browser processes..."
|
||||||
|
for pid in "${browser_pids[@]}"; do
|
||||||
|
kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
leftover_pg_pids=()
|
leftover_pg_pids=()
|
||||||
leftover_pg_data_dirs=()
|
leftover_pg_data_dirs=()
|
||||||
for i in "${!pg_pids[@]:-}"; do
|
for i in "${!pg_pids[@]:-}"; do
|
||||||
|
|
@ -203,4 +236,13 @@ if [[ ${#pg_pids[@]} -gt 0 ]]; then
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ ${#browser_pids[@]} -gt 0 ]]; then
|
||||||
|
for pid in "${browser_pids[@]:-}"; do
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo " agent browser $pid still alive, sending SIGKILL..."
|
||||||
|
kill -KILL "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Done."
|
echo "Done."
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,15 @@
|
||||||
"skills"
|
"skills"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"dev": "pnpm run preflight:workspace-links && tsx src/index.ts",
|
"dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
||||||
"dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts",
|
|
||||||
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
"prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh",
|
||||||
"build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
"build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/",
|
||||||
"prepack": "pnpm run prepare:ui-dist",
|
"prepack": "pnpm run prepare:ui-dist",
|
||||||
"postpack": "rm -rf ui-dist",
|
"postpack": "rm -rf ui-dist",
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.888.0",
|
"@aws-sdk/client-s3": "^3.888.0",
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,80 @@ describe("agent permission routes", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes direct agent creation to disable timer heartbeats by default", async () => {
|
||||||
|
const app = await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/companies/${companyId}/agents`)
|
||||||
|
.send({
|
||||||
|
name: "Builder",
|
||||||
|
role: "engineer",
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
intervalSec: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||||
|
companyId,
|
||||||
|
expect.objectContaining({
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: false,
|
||||||
|
intervalSec: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes hire requests to disable timer heartbeats by default", async () => {
|
||||||
|
const app = await createApp({
|
||||||
|
type: "board",
|
||||||
|
userId: "board-user",
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: true,
|
||||||
|
companyIds: [companyId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/companies/${companyId}/agent-hires`)
|
||||||
|
.send({
|
||||||
|
name: "Builder",
|
||||||
|
role: "engineer",
|
||||||
|
adapterType: "process",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
intervalSec: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||||
|
companyId,
|
||||||
|
expect.objectContaining({
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: false,
|
||||||
|
intervalSec: 3600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("exposes explicit task assignment access on agent detail", async () => {
|
it("exposes explicit task assignment access on agent detail", async () => {
|
||||||
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
mockAccessService.listPrincipalGrants.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -369,6 +369,252 @@ describe("codex execute", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders execution-stage wake instructions for reviewer and executor roles", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-stage-wake-"));
|
||||||
|
const workspace = path.join(root, "workspace");
|
||||||
|
const commandPath = path.join(root, "codex");
|
||||||
|
const capturePath = path.join(root, "capture.json");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await writeFakeCodexCommand(commandPath);
|
||||||
|
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
process.env.HOME = root;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execute({
|
||||||
|
runId: "run-stage-wake",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
command: commandPath,
|
||||||
|
cwd: workspace,
|
||||||
|
env: {
|
||||||
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||||
|
},
|
||||||
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
issueId: "issue-1",
|
||||||
|
taskId: "issue-1",
|
||||||
|
wakeReason: "execution_review_requested",
|
||||||
|
paperclipWake: {
|
||||||
|
reason: "execution_review_requested",
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1207",
|
||||||
|
title: "implement the plan of PAP-1200",
|
||||||
|
status: "in_review",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
executionStage: {
|
||||||
|
wakeRole: "reviewer",
|
||||||
|
stageId: "stage-1",
|
||||||
|
stageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||||
|
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||||
|
lastDecisionOutcome: null,
|
||||||
|
allowedActions: ["approve", "request_changes"],
|
||||||
|
},
|
||||||
|
commentIds: [],
|
||||||
|
latestCommentId: null,
|
||||||
|
comments: [],
|
||||||
|
commentWindow: {
|
||||||
|
requestedCount: 0,
|
||||||
|
includedCount: 0,
|
||||||
|
missingCount: 0,
|
||||||
|
},
|
||||||
|
truncated: false,
|
||||||
|
fallbackFetchNeeded: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: "run-jwt-token",
|
||||||
|
onLog: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
|
expect(capture.prompt).toContain("execution wake role: reviewer");
|
||||||
|
expect(capture.prompt).toContain("You are waking as the active reviewer for this issue.");
|
||||||
|
expect(capture.prompt).toContain("Do not execute the task itself or continue executor work.");
|
||||||
|
expect(capture.prompt).toContain("allowed actions: approve, request_changes");
|
||||||
|
|
||||||
|
const executorCapturePath = path.join(root, "capture-executor.json");
|
||||||
|
const executorResult = await execute({
|
||||||
|
runId: "run-stage-wake-executor",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
command: commandPath,
|
||||||
|
cwd: workspace,
|
||||||
|
env: {
|
||||||
|
PAPERCLIP_TEST_CAPTURE_PATH: executorCapturePath,
|
||||||
|
},
|
||||||
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
issueId: "issue-1",
|
||||||
|
taskId: "issue-1",
|
||||||
|
wakeReason: "execution_changes_requested",
|
||||||
|
paperclipWake: {
|
||||||
|
reason: "execution_changes_requested",
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1207",
|
||||||
|
title: "implement the plan of PAP-1200",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
executionStage: {
|
||||||
|
wakeRole: "executor",
|
||||||
|
stageId: "stage-1",
|
||||||
|
stageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: "qa-agent" },
|
||||||
|
returnAssignee: { type: "agent", agentId: "coder-agent" },
|
||||||
|
lastDecisionOutcome: "changes_requested",
|
||||||
|
allowedActions: ["address_changes", "resubmit"],
|
||||||
|
},
|
||||||
|
commentIds: [],
|
||||||
|
latestCommentId: null,
|
||||||
|
comments: [],
|
||||||
|
commentWindow: {
|
||||||
|
requestedCount: 0,
|
||||||
|
includedCount: 0,
|
||||||
|
missingCount: 0,
|
||||||
|
},
|
||||||
|
truncated: false,
|
||||||
|
fallbackFetchNeeded: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: "run-jwt-token",
|
||||||
|
onLog: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(executorResult.exitCode).toBe(0);
|
||||||
|
const executorCapture = JSON.parse(await fs.readFile(executorCapturePath, "utf8")) as CapturePayload;
|
||||||
|
expect(executorCapture.prompt).toContain("execution wake role: executor");
|
||||||
|
expect(executorCapture.prompt).toContain("You are waking because changes were requested in the execution workflow.");
|
||||||
|
expect(executorCapture.prompt).toContain("allowed actions: address_changes, resubmit");
|
||||||
|
} finally {
|
||||||
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
|
else process.env.HOME = previousHome;
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an issue-scoped wake prompt even when the wake has no comments yet", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-issue-wake-"));
|
||||||
|
const workspace = path.join(root, "workspace");
|
||||||
|
const commandPath = path.join(root, "codex");
|
||||||
|
const capturePath = path.join(root, "capture.json");
|
||||||
|
await fs.mkdir(workspace, { recursive: true });
|
||||||
|
await writeFakeCodexCommand(commandPath);
|
||||||
|
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
process.env.HOME = root;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execute({
|
||||||
|
runId: "run-issue-wake",
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
command: commandPath,
|
||||||
|
cwd: workspace,
|
||||||
|
env: {
|
||||||
|
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||||
|
},
|
||||||
|
promptTemplate: "Follow the paperclip heartbeat.",
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
issueId: "issue-1",
|
||||||
|
taskId: "issue-1",
|
||||||
|
wakeReason: "issue_assigned",
|
||||||
|
paperclipWake: {
|
||||||
|
reason: "issue_assigned",
|
||||||
|
issue: {
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-1201",
|
||||||
|
title: "Fix gallery opening for inline images",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
commentIds: [],
|
||||||
|
latestCommentId: null,
|
||||||
|
comments: [],
|
||||||
|
commentWindow: {
|
||||||
|
requestedCount: 0,
|
||||||
|
includedCount: 0,
|
||||||
|
missingCount: 0,
|
||||||
|
},
|
||||||
|
truncated: false,
|
||||||
|
fallbackFetchNeeded: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: "run-jwt-token",
|
||||||
|
onLog: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.errorMessage).toBeNull();
|
||||||
|
|
||||||
|
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||||
|
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||||
|
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||||
|
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||||
|
reason: "issue_assigned",
|
||||||
|
issue: {
|
||||||
|
identifier: "PAP-1201",
|
||||||
|
title: "Fix gallery opening for inline images",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
commentIds: [],
|
||||||
|
});
|
||||||
|
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||||
|
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||||
|
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
|
||||||
|
expect(capture.prompt).toContain("- pending comments: 0/0");
|
||||||
|
expect(capture.prompt).toContain("- issue status: todo");
|
||||||
|
} finally {
|
||||||
|
if (previousHome === undefined) delete process.env.HOME;
|
||||||
|
else process.env.HOME = previousHome;
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
||||||
const workspace = path.join(root, "workspace");
|
const workspace = path.join(root, "workspace");
|
||||||
|
|
|
||||||
|
|
@ -488,6 +488,23 @@ describe("heartbeat comment wake batching", () => {
|
||||||
|
|
||||||
expect(firstRun).not.toBeNull();
|
expect(firstRun).not.toBeNull();
|
||||||
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
await waitFor(() => gateway.getAgentPayloads().length === 1);
|
||||||
|
const firstPayload = gateway.getAgentPayloads()[0] ?? {};
|
||||||
|
expect(firstPayload.paperclip).toMatchObject({
|
||||||
|
wake: {
|
||||||
|
reason: "issue_assigned",
|
||||||
|
issue: {
|
||||||
|
id: issueId,
|
||||||
|
identifier: `${issuePrefix}-1`,
|
||||||
|
title: "Require a comment",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
},
|
||||||
|
commentIds: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
|
||||||
|
expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake.");
|
||||||
|
expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`);
|
||||||
gateway.releaseFirstWait();
|
gateway.releaseFirstWait();
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
const runs = await db
|
const runs = await db
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,18 @@ describe("shouldResetTaskSessionForWake", () => {
|
||||||
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resets session context on execution review wakes", () => {
|
||||||
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_review_requested" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets session context on execution approval wakes", () => {
|
||||||
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_approval_requested" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets session context on execution changes-requested wakes", () => {
|
||||||
|
expect(shouldResetTaskSessionForWake({ wakeReason: "execution_changes_requested" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves session context on timer heartbeats", () => {
|
it("preserves session context on timer heartbeats", () => {
|
||||||
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
expect(shouldResetTaskSessionForWake({ wakeSource: "timer" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
212
server/src/__tests__/inbox-dismissals.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
agents,
|
||||||
|
approvals,
|
||||||
|
companies,
|
||||||
|
createDb,
|
||||||
|
heartbeatRuns,
|
||||||
|
inboxDismissals,
|
||||||
|
invites,
|
||||||
|
joinRequests,
|
||||||
|
} from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { inboxDismissalService } from "../services/inbox-dismissals.ts";
|
||||||
|
import { sidebarBadgeService } from "../services/sidebar-badges.ts";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres inbox dismissal tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describeEmbeddedPostgres("inbox dismissals", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let dismissalsSvc!: ReturnType<typeof inboxDismissalService>;
|
||||||
|
let badgesSvc!: ReturnType<typeof sidebarBadgeService>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-inbox-dismissals-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
dismissalsSvc = inboxDismissalService(db);
|
||||||
|
badgesSvc = sidebarBadgeService(db);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(inboxDismissals);
|
||||||
|
await db.delete(joinRequests);
|
||||||
|
await db.delete(invites);
|
||||||
|
await db.delete(heartbeatRuns);
|
||||||
|
await db.delete(approvals);
|
||||||
|
await db.delete(agents);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upserts a single dismissal record per user and inbox item key", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const userId = "board-user";
|
||||||
|
const firstDismissedAt = new Date("2026-03-11T01:00:00.000Z");
|
||||||
|
const secondDismissedAt = new Date("2026-03-11T02:00:00.000Z");
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", firstDismissedAt);
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, "approval:approval-1", secondDismissedAt);
|
||||||
|
|
||||||
|
const dismissals = await dismissalsSvc.list(companyId, userId);
|
||||||
|
|
||||||
|
expect(dismissals).toHaveLength(1);
|
||||||
|
expect(dismissals[0]?.itemKey).toBe("approval:approval-1");
|
||||||
|
expect(new Date(dismissals[0]?.dismissedAt ?? 0).toISOString()).toBe(secondDismissedAt.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors dismissal timestamps and resurfaces approvals with newer activity", async () => {
|
||||||
|
const companyId = randomUUID();
|
||||||
|
const userId = "board-user";
|
||||||
|
const primaryAgentId = randomUUID();
|
||||||
|
const secondaryAgentId = randomUUID();
|
||||||
|
const hiddenApprovalId = randomUUID();
|
||||||
|
const resurfacedApprovalId = randomUUID();
|
||||||
|
const inviteId = randomUUID();
|
||||||
|
const hiddenJoinRequestId = randomUUID();
|
||||||
|
const hiddenRunId = randomUUID();
|
||||||
|
const visibleRunId = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(companies).values({
|
||||||
|
id: companyId,
|
||||||
|
name: "Paperclip",
|
||||||
|
issuePrefix: "PAP",
|
||||||
|
requireBoardApprovalForNewAgents: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(agents).values([
|
||||||
|
{
|
||||||
|
id: primaryAgentId,
|
||||||
|
companyId,
|
||||||
|
name: "Primary",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: secondaryAgentId,
|
||||||
|
companyId,
|
||||||
|
name: "Secondary",
|
||||||
|
role: "engineer",
|
||||||
|
status: "active",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
adapterConfig: {},
|
||||||
|
runtimeConfig: {},
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(approvals).values([
|
||||||
|
{
|
||||||
|
id: hiddenApprovalId,
|
||||||
|
companyId,
|
||||||
|
type: "hire_agent",
|
||||||
|
status: "pending",
|
||||||
|
payload: {},
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: resurfacedApprovalId,
|
||||||
|
companyId,
|
||||||
|
type: "hire_agent",
|
||||||
|
status: "revision_requested",
|
||||||
|
payload: {},
|
||||||
|
updatedAt: new Date("2026-03-11T03:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await db.insert(invites).values({
|
||||||
|
id: inviteId,
|
||||||
|
companyId,
|
||||||
|
inviteType: "company_join",
|
||||||
|
tokenHash: "hash-1",
|
||||||
|
allowedJoinTypes: "both",
|
||||||
|
expiresAt: new Date("2026-03-12T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(joinRequests).values({
|
||||||
|
id: hiddenJoinRequestId,
|
||||||
|
inviteId,
|
||||||
|
companyId,
|
||||||
|
requestType: "human",
|
||||||
|
status: "pending_approval",
|
||||||
|
requestIp: "127.0.0.1",
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(heartbeatRuns).values([
|
||||||
|
{
|
||||||
|
id: hiddenRunId,
|
||||||
|
companyId,
|
||||||
|
agentId: primaryAgentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "failed",
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: visibleRunId,
|
||||||
|
companyId,
|
||||||
|
agentId: secondaryAgentId,
|
||||||
|
invocationSource: "assignment",
|
||||||
|
status: "timed_out",
|
||||||
|
createdAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T04:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `approval:${hiddenApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `approval:${resurfacedApprovalId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `join:${hiddenJoinRequestId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
await dismissalsSvc.dismiss(companyId, userId, `run:${hiddenRunId}`, new Date("2026-03-11T02:00:00.000Z"));
|
||||||
|
|
||||||
|
const dismissedAtByKey = new Map(
|
||||||
|
(await dismissalsSvc.list(companyId, userId)).map((dismissal) => [
|
||||||
|
dismissal.itemKey,
|
||||||
|
new Date(dismissal.dismissedAt).getTime(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const badges = await badgesSvc.get(companyId, {
|
||||||
|
dismissals: dismissedAtByKey,
|
||||||
|
joinRequests: [{
|
||||||
|
id: hiddenJoinRequestId,
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
}],
|
||||||
|
unreadTouchedIssues: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(badges).toEqual({
|
||||||
|
inbox: 3,
|
||||||
|
approvals: 1,
|
||||||
|
failedRuns: 1,
|
||||||
|
joinRequests: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
244
server/src/__tests__/issue-activity-events-routes.test.ts
Normal file
244
server/src/__tests__/issue-activity-events-routes.test.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
import { issueRoutes } from "../routes/issues.js";
|
||||||
|
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||||
|
|
||||||
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
assertCheckoutOwner: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
addComment: vi.fn(),
|
||||||
|
findMentionedAgents: vi.fn(),
|
||||||
|
getRelationSummaries: vi.fn(),
|
||||||
|
listWakeableBlockedDependents: vi.fn(),
|
||||||
|
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||||
|
|
||||||
|
vi.mock("../services/index.js", () => ({
|
||||||
|
accessService: () => ({
|
||||||
|
canUser: vi.fn(async () => false),
|
||||||
|
hasPermission: vi.fn(async () => false),
|
||||||
|
}),
|
||||||
|
agentService: () => ({
|
||||||
|
getById: vi.fn(async () => null),
|
||||||
|
}),
|
||||||
|
documentService: () => ({}),
|
||||||
|
executionWorkspaceService: () => ({}),
|
||||||
|
feedbackService: () => ({
|
||||||
|
listIssueVotesForUser: vi.fn(async () => []),
|
||||||
|
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||||
|
}),
|
||||||
|
goalService: () => ({}),
|
||||||
|
heartbeatService: () => ({
|
||||||
|
wakeup: vi.fn(async () => undefined),
|
||||||
|
reportRunActivity: vi.fn(async () => undefined),
|
||||||
|
getRun: vi.fn(async () => null),
|
||||||
|
getActiveRunForAgent: vi.fn(async () => null),
|
||||||
|
cancelRun: vi.fn(async () => null),
|
||||||
|
}),
|
||||||
|
instanceSettingsService: () => ({
|
||||||
|
get: vi.fn(async () => ({
|
||||||
|
id: "instance-settings-1",
|
||||||
|
general: {
|
||||||
|
censorUsernameInLogs: false,
|
||||||
|
feedbackDataSharingPreference: "prompt",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
|
}),
|
||||||
|
issueApprovalService: () => ({}),
|
||||||
|
issueService: () => mockIssueService,
|
||||||
|
logActivity: mockLogActivity,
|
||||||
|
projectService: () => ({}),
|
||||||
|
routineService: () => ({
|
||||||
|
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
workProductService: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = {
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", issueRoutes({} as any, {} as any));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIssue() {
|
||||||
|
return {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
companyId: "company-1",
|
||||||
|
status: "todo",
|
||||||
|
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByUserId: "local-board",
|
||||||
|
identifier: "PAP-580",
|
||||||
|
title: "Activity event issue",
|
||||||
|
executionPolicy: null,
|
||||||
|
executionState: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issue activity event routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||||
|
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||||
|
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||||
|
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||||
|
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs blocker activity with added and removed issue summaries", async () => {
|
||||||
|
const issue = makeIssue();
|
||||||
|
mockIssueService.getById.mockResolvedValue(issue);
|
||||||
|
mockIssueService.getRelationSummaries
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
blockedBy: [
|
||||||
|
{
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
identifier: "PAP-10",
|
||||||
|
title: "Old blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blocks: [],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
blockedBy: [
|
||||||
|
{
|
||||||
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
identifier: "PAP-11",
|
||||||
|
title: "New blocker",
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blocks: [],
|
||||||
|
});
|
||||||
|
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||||
|
...issue,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await request(createApp())
|
||||||
|
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||||
|
.send({ blockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "issue.blockers_updated",
|
||||||
|
details: expect.objectContaining({
|
||||||
|
addedBlockedByIssueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
|
||||||
|
removedBlockedByIssueIds: ["aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"],
|
||||||
|
addedBlockedByIssues: [
|
||||||
|
{
|
||||||
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
identifier: "PAP-11",
|
||||||
|
title: "New blocker",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
removedBlockedByIssues: [
|
||||||
|
{
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
identifier: "PAP-10",
|
||||||
|
title: "Old blocker",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logs explicit reviewer and approver activity when execution policy participants change", async () => {
|
||||||
|
const existingPolicy = normalizeIssueExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "22222222-2222-4222-8222-222222222222",
|
||||||
|
type: "approval",
|
||||||
|
participants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})!;
|
||||||
|
const nextPolicy = normalizeIssueExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "22222222-2222-4222-8222-222222222222",
|
||||||
|
type: "approval",
|
||||||
|
participants: [{ type: "user", userId: "local-board" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})!;
|
||||||
|
const issue = {
|
||||||
|
...makeIssue(),
|
||||||
|
executionPolicy: existingPolicy,
|
||||||
|
};
|
||||||
|
mockIssueService.getById.mockResolvedValue(issue);
|
||||||
|
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||||
|
...issue,
|
||||||
|
...patch,
|
||||||
|
executionPolicy: patch.executionPolicy,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await request(createApp())
|
||||||
|
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||||
|
.send({ executionPolicy: nextPolicy });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "issue.reviewers_updated",
|
||||||
|
details: expect.objectContaining({
|
||||||
|
participants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||||
|
addedParticipants: [{ type: "agent", agentId: "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff", userId: null }],
|
||||||
|
removedParticipants: [{ type: "agent", agentId: "11111111-2222-4333-8444-555555555555", userId: null }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
action: "issue.approvers_updated",
|
||||||
|
details: expect.objectContaining({
|
||||||
|
participants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||||
|
addedParticipants: [{ type: "user", agentId: null, userId: "local-board" }],
|
||||||
|
removedParticipants: [{ type: "agent", agentId: "66666666-7777-4888-8999-aaaaaaaaaaaa", userId: null }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -93,7 +93,7 @@ async function installActor(app: express.Express, actor?: Record<string, unknown
|
||||||
import("../middleware/index.js"),
|
import("../middleware/index.js"),
|
||||||
]);
|
]);
|
||||||
app.use((req, _res, next) => {
|
app.use((req, _res, next) => {
|
||||||
(req as any).actor = {
|
(req as any).actor = actor ?? {
|
||||||
type: "board",
|
type: "board",
|
||||||
userId: "local-board",
|
userId: "local-board",
|
||||||
companyIds: ["company-1"],
|
companyIds: ["company-1"],
|
||||||
|
|
@ -176,6 +176,10 @@ describe("issue comment reopen routes", () => {
|
||||||
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||||
|
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||||
|
mockAccessService.canUser.mockResolvedValue(false);
|
||||||
|
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||||
|
mockAgentService.getById.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
it("treats reopen=true as a no-op when the issue is already open", async () => {
|
||||||
|
|
@ -343,4 +347,146 @@ describe("issue comment reopen routes", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coerces executor handoff patches into workflow-controlled review wakes", async () => {
|
||||||
|
const policy = await normalizePolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})!;
|
||||||
|
const issue = {
|
||||||
|
...makeIssue("todo"),
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
executionPolicy: policy,
|
||||||
|
executionState: null,
|
||||||
|
};
|
||||||
|
mockIssueService.getById.mockResolvedValue(issue);
|
||||||
|
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||||
|
...issue,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
await installActor(createApp(), {
|
||||||
|
type: "agent",
|
||||||
|
agentId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
companyId: "company-1",
|
||||||
|
runId: "run-1",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||||
|
.send({
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: "local-board",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||||
|
"11111111-1111-4111-8111-111111111111",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionState: expect.objectContaining({
|
||||||
|
status: "pending",
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: expect.objectContaining({
|
||||||
|
type: "agent",
|
||||||
|
agentId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
}),
|
||||||
|
returnAssignee: expect.objectContaining({
|
||||||
|
type: "agent",
|
||||||
|
agentId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
|
"33333333-3333-4333-8333-333333333333",
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "execution_review_requested",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
issueId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
executionStage: expect.objectContaining({
|
||||||
|
wakeRole: "reviewer",
|
||||||
|
stageType: "review",
|
||||||
|
allowedActions: ["approve", "request_changes"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wakes the return assignee with execution_changes_requested", async () => {
|
||||||
|
const policy = await normalizePolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})!;
|
||||||
|
const issue = {
|
||||||
|
...makeIssue("todo"),
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
executionPolicy: policy,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: policy.stages[0].id,
|
||||||
|
currentStageIndex: 0,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: "33333333-3333-4333-8333-333333333333" },
|
||||||
|
returnAssignee: { type: "agent", agentId: "22222222-2222-4222-8222-222222222222" },
|
||||||
|
completedStageIds: [],
|
||||||
|
lastDecisionId: null,
|
||||||
|
lastDecisionOutcome: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockIssueService.getById.mockResolvedValue(issue);
|
||||||
|
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||||
|
...issue,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
await installActor(createApp(), {
|
||||||
|
type: "agent",
|
||||||
|
agentId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
companyId: "company-1",
|
||||||
|
runId: "run-2",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
|
||||||
|
.send({
|
||||||
|
status: "in_progress",
|
||||||
|
comment: "Needs another pass",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
|
||||||
|
"22222222-2222-4222-8222-222222222222",
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: "execution_changes_requested",
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
issueId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
executionStage: expect.objectContaining({
|
||||||
|
wakeRole: "executor",
|
||||||
|
stageType: "review",
|
||||||
|
lastDecisionOutcome: "changes_requested",
|
||||||
|
allowedActions: ["address_changes", "resubmit"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
201
server/src/__tests__/issue-execution-policy-routes.test.ts
Normal file
201
server/src/__tests__/issue-execution-policy-routes.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
import { issueRoutes } from "../routes/issues.js";
|
||||||
|
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
|
||||||
|
|
||||||
|
const mockIssueService = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
assertCheckoutOwner: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
addComment: vi.fn(),
|
||||||
|
findMentionedAgents: vi.fn(),
|
||||||
|
getRelationSummaries: vi.fn(),
|
||||||
|
listWakeableBlockedDependents: vi.fn(),
|
||||||
|
getWakeableParentAfterChildCompletion: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockHeartbeatService = vi.hoisted(() => ({
|
||||||
|
wakeup: vi.fn(async () => undefined),
|
||||||
|
reportRunActivity: vi.fn(async () => undefined),
|
||||||
|
getRun: vi.fn(async () => null),
|
||||||
|
getActiveRunForAgent: vi.fn(async () => null),
|
||||||
|
cancelRun: vi.fn(async () => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../services/index.js", () => ({
|
||||||
|
accessService: () => ({
|
||||||
|
canUser: vi.fn(async () => false),
|
||||||
|
hasPermission: vi.fn(async () => false),
|
||||||
|
}),
|
||||||
|
agentService: () => ({
|
||||||
|
getById: vi.fn(async () => null),
|
||||||
|
}),
|
||||||
|
documentService: () => ({}),
|
||||||
|
executionWorkspaceService: () => ({}),
|
||||||
|
feedbackService: () => ({
|
||||||
|
listIssueVotesForUser: vi.fn(async () => []),
|
||||||
|
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||||
|
}),
|
||||||
|
goalService: () => ({}),
|
||||||
|
heartbeatService: () => mockHeartbeatService,
|
||||||
|
instanceSettingsService: () => ({
|
||||||
|
get: vi.fn(async () => ({
|
||||||
|
id: "instance-settings-1",
|
||||||
|
general: {
|
||||||
|
censorUsernameInLogs: false,
|
||||||
|
feedbackDataSharingPreference: "prompt",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
listCompanyIds: vi.fn(async () => ["company-1"]),
|
||||||
|
}),
|
||||||
|
issueApprovalService: () => ({}),
|
||||||
|
issueService: () => mockIssueService,
|
||||||
|
logActivity: vi.fn(async () => undefined),
|
||||||
|
projectService: () => ({}),
|
||||||
|
routineService: () => ({
|
||||||
|
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
workProductService: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createApp(
|
||||||
|
actor: Record<string, unknown> = {
|
||||||
|
type: "board",
|
||||||
|
userId: "local-board",
|
||||||
|
companyIds: ["company-1"],
|
||||||
|
source: "local_implicit",
|
||||||
|
isInstanceAdmin: false,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
(req as any).actor = actor;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use("/api", issueRoutes({} as any, {} as any));
|
||||||
|
app.use(errorHandler);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issue execution policy routes", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
|
||||||
|
mockIssueService.findMentionedAgents.mockResolvedValue([]);
|
||||||
|
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||||
|
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||||
|
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
|
||||||
|
const policy = normalizeIssueExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: "33333333-3333-4333-8333-333333333333" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})!;
|
||||||
|
const issue = {
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
companyId: "company-1",
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: "local-board",
|
||||||
|
createdByUserId: "local-board",
|
||||||
|
identifier: "PAP-999",
|
||||||
|
title: "Execution policy edit",
|
||||||
|
executionPolicy: null,
|
||||||
|
executionState: null,
|
||||||
|
};
|
||||||
|
mockIssueService.getById.mockResolvedValue(issue);
|
||||||
|
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||||
|
...issue,
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await request(createApp())
|
||||||
|
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||||
|
.send({ executionPolicy: policy });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockIssueService.update).toHaveBeenCalledWith(
|
||||||
|
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
expect.objectContaining({
|
||||||
|
executionPolicy: policy,
|
||||||
|
actorAgentId: null,
|
||||||
|
actorUserId: "local-board",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const updatePatch = mockIssueService.update.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||||
|
expect(updatePatch.status).toBeUndefined();
|
||||||
|
expect(updatePatch.assigneeAgentId).toBeUndefined();
|
||||||
|
expect(updatePatch.assigneeUserId).toBeUndefined();
|
||||||
|
expect(updatePatch.executionState).toBeUndefined();
|
||||||
|
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects agent stage advances from non-participants", async () => {
|
||||||
|
const reviewerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const approverAgentId = "44444444-4444-4444-8444-444444444444";
|
||||||
|
const executorAgentId = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const policy = normalizeIssueExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: reviewerAgentId }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "55555555-5555-4555-8555-555555555555",
|
||||||
|
type: "approval",
|
||||||
|
participants: [{ type: "agent", agentId: approverAgentId }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})!;
|
||||||
|
const issue = {
|
||||||
|
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
companyId: "company-1",
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: reviewerAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
createdByUserId: "local-board",
|
||||||
|
identifier: "PAP-1000",
|
||||||
|
title: "Execution policy guard",
|
||||||
|
executionPolicy: policy,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
currentStageIndex: 0,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: reviewerAgentId },
|
||||||
|
returnAssignee: { type: "agent", agentId: executorAgentId },
|
||||||
|
completedStageIds: [],
|
||||||
|
lastDecisionId: null,
|
||||||
|
lastDecisionOutcome: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
mockIssueService.getById.mockResolvedValue(issue);
|
||||||
|
|
||||||
|
const res = await request(
|
||||||
|
createApp({
|
||||||
|
type: "agent",
|
||||||
|
agentId: approverAgentId,
|
||||||
|
companyId: "company-1",
|
||||||
|
source: "api_key",
|
||||||
|
runId: "run-1",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
|
||||||
|
.send({ status: "done", comment: "Skipping review." });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain("active review participant");
|
||||||
|
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -413,33 +413,45 @@ describe("issue execution policy transitions", () => {
|
||||||
const policy = twoStagePolicy();
|
const policy = twoStagePolicy();
|
||||||
const reviewStageId = policy.stages[0].id;
|
const reviewStageId = policy.stages[0].id;
|
||||||
|
|
||||||
it("non-participant cannot advance stage via status change", () => {
|
it("non-participant stage updates are coerced back to the active stage", () => {
|
||||||
expect(() =>
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
applyIssueExecutionPolicyTransition({
|
issue: {
|
||||||
issue: {
|
status: "in_review",
|
||||||
status: "in_review",
|
assigneeAgentId: qaAgentId,
|
||||||
assigneeAgentId: qaAgentId,
|
assigneeUserId: null,
|
||||||
assigneeUserId: null,
|
executionPolicy: policy,
|
||||||
executionPolicy: policy,
|
executionState: {
|
||||||
executionState: {
|
status: "pending",
|
||||||
status: "pending",
|
currentStageId: reviewStageId,
|
||||||
currentStageId: reviewStageId,
|
currentStageIndex: 0,
|
||||||
currentStageIndex: 0,
|
currentStageType: "review",
|
||||||
currentStageType: "review",
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||||
currentParticipant: { type: "agent", agentId: qaAgentId },
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||||
returnAssignee: { type: "agent", agentId: coderAgentId },
|
completedStageIds: [],
|
||||||
completedStageIds: [],
|
lastDecisionId: null,
|
||||||
lastDecisionId: null,
|
lastDecisionOutcome: null,
|
||||||
lastDecisionOutcome: null,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
policy,
|
},
|
||||||
requestedStatus: "done",
|
policy,
|
||||||
requestedAssigneePatch: {},
|
requestedStatus: "done",
|
||||||
actor: { agentId: coderAgentId },
|
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
||||||
commentBody: "Trying to bypass review",
|
actor: { agentId: coderAgentId },
|
||||||
}),
|
commentBody: "Trying to bypass review",
|
||||||
).toThrow("Only the active reviewer or approver can advance");
|
});
|
||||||
|
|
||||||
|
expect(result.patch).toMatchObject({
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: qaAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: reviewStageId,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||||
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.decision).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("non-participant can still post non-advancing updates", () => {
|
it("non-participant can still post non-advancing updates", () => {
|
||||||
|
|
@ -663,6 +675,7 @@ describe("issue execution policy transitions", () => {
|
||||||
|
|
||||||
describe("no-op transitions", () => {
|
describe("no-op transitions", () => {
|
||||||
const policy = twoStagePolicy();
|
const policy = twoStagePolicy();
|
||||||
|
const reviewStageId = policy.stages[0].id;
|
||||||
|
|
||||||
it("non-done status change without review context is a no-op", () => {
|
it("non-done status change without review context is a no-op", () => {
|
||||||
const result = applyIssueExecutionPolicyTransition({
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
|
|
@ -682,6 +695,72 @@ describe("issue execution policy transitions", () => {
|
||||||
expect(result.patch).toEqual({});
|
expect(result.patch).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coerces a malformed executor in_review patch into the first policy stage", () => {
|
||||||
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
|
issue: {
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: coderAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionPolicy: policy,
|
||||||
|
executionState: null,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
requestedStatus: "in_review",
|
||||||
|
requestedAssigneePatch: { assigneeUserId: boardUserId },
|
||||||
|
actor: { agentId: coderAgentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.patch).toMatchObject({
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: qaAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||||
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reasserts the active stage when issue status drifted out of in_review", () => {
|
||||||
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
|
issue: {
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: coderAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionPolicy: policy,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: reviewStageId,
|
||||||
|
currentStageIndex: 0,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||||
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||||
|
completedStageIds: [],
|
||||||
|
lastDecisionId: null,
|
||||||
|
lastDecisionOutcome: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
requestedStatus: "in_progress",
|
||||||
|
requestedAssigneePatch: { assigneeAgentId: coderAgentId },
|
||||||
|
actor: { agentId: coderAgentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.patch).toMatchObject({
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: qaAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: reviewStageId,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("no policy and no state is a no-op", () => {
|
it("no policy and no state is a no-op", () => {
|
||||||
const result = applyIssueExecutionPolicyTransition({
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
issue: {
|
issue: {
|
||||||
|
|
@ -699,6 +778,25 @@ describe("issue execution policy transitions", () => {
|
||||||
|
|
||||||
expect(result.patch).toEqual({});
|
expect(result.patch).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not auto-start workflow when policy is added to an already in_review issue", () => {
|
||||||
|
const reviewOnly = reviewOnlyPolicy();
|
||||||
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
|
issue: {
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: boardUserId,
|
||||||
|
executionPolicy: null,
|
||||||
|
executionState: null,
|
||||||
|
},
|
||||||
|
policy: reviewOnly,
|
||||||
|
requestedStatus: undefined,
|
||||||
|
requestedAssigneePatch: {},
|
||||||
|
actor: { userId: boardUserId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.patch).toEqual({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("multi-participant stages", () => {
|
describe("multi-participant stages", () => {
|
||||||
|
|
@ -895,4 +993,100 @@ describe("issue execution policy transitions", () => {
|
||||||
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
expect(result.patch.assigneeUserId).toBe(boardUserId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("policy edits while a stage is active", () => {
|
||||||
|
it("clears the active execution state when its stage is removed from the policy", () => {
|
||||||
|
const reviewAndApproval = twoStagePolicy();
|
||||||
|
const approvalOnly = approvalOnlyPolicy();
|
||||||
|
|
||||||
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
|
issue: {
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: qaAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionPolicy: reviewAndApproval,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: reviewAndApproval.stages[0].id,
|
||||||
|
currentStageIndex: 0,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||||
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||||
|
completedStageIds: [],
|
||||||
|
lastDecisionId: null,
|
||||||
|
lastDecisionOutcome: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policy: approvalOnly,
|
||||||
|
requestedStatus: undefined,
|
||||||
|
requestedAssigneePatch: {},
|
||||||
|
actor: { userId: boardUserId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.patch).toMatchObject({
|
||||||
|
status: "in_progress",
|
||||||
|
assigneeAgentId: coderAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionState: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reassigns the active stage when the current participant is removed", () => {
|
||||||
|
const policy = makePolicy([
|
||||||
|
{
|
||||||
|
type: "review",
|
||||||
|
participants: [
|
||||||
|
{ type: "agent", agentId: qaAgentId },
|
||||||
|
{ type: "agent", agentId: ctoAgentId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const updatedPolicy = makePolicy([
|
||||||
|
{
|
||||||
|
type: "review",
|
||||||
|
participants: [{ type: "agent", agentId: ctoAgentId }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = applyIssueExecutionPolicyTransition({
|
||||||
|
issue: {
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: qaAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionPolicy: policy,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: policy.stages[0].id,
|
||||||
|
currentStageIndex: 0,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: qaAgentId },
|
||||||
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||||
|
completedStageIds: [],
|
||||||
|
lastDecisionId: null,
|
||||||
|
lastDecisionOutcome: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
...updatedPolicy,
|
||||||
|
stages: [{ ...updatedPolicy.stages[0], id: policy.stages[0].id }],
|
||||||
|
},
|
||||||
|
requestedStatus: undefined,
|
||||||
|
requestedAssigneePatch: {},
|
||||||
|
actor: { userId: boardUserId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.patch).toMatchObject({
|
||||||
|
status: "in_review",
|
||||||
|
assigneeAgentId: ctoAgentId,
|
||||||
|
assigneeUserId: null,
|
||||||
|
executionState: {
|
||||||
|
status: "pending",
|
||||||
|
currentStageId: policy.stages[0].id,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: ctoAgentId },
|
||||||
|
returnAssignee: { type: "agent", agentId: coderAgentId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
projectId,
|
projectId,
|
||||||
goalId: null,
|
goalId: null,
|
||||||
parentIssueId: null,
|
parentIssueId: null,
|
||||||
title: "repo triage",
|
title: "repo triage for {{repo}}",
|
||||||
description: "Review {{repo}} for {{priority}} bugs",
|
description: "Review {{repo}} for {{priority}} bugs",
|
||||||
assigneeAgentId: agentId,
|
assigneeAgentId: agentId,
|
||||||
priority: "medium",
|
priority: "medium",
|
||||||
|
|
@ -346,6 +346,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
expect(variableRoutine.variables.map((variable) => variable.name)).toEqual(["repo", "priority"]);
|
||||||
|
|
||||||
const run = await svc.runRoutine(variableRoutine.id, {
|
const run = await svc.runRoutine(variableRoutine.id, {
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
|
@ -353,7 +354,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const storedIssue = await db
|
const storedIssue = await db
|
||||||
.select({ description: issues.description })
|
.select({ title: issues.title, description: issues.description })
|
||||||
.from(issues)
|
.from(issues)
|
||||||
.where(eq(issues.id, run.linkedIssueId!))
|
.where(eq(issues.id, run.linkedIssueId!))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
@ -363,6 +364,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||||
.where(eq(routineRuns.id, run.id))
|
.where(eq(routineRuns.id, run.id))
|
||||||
.then((rows) => rows[0] ?? null);
|
.then((rows) => rows[0] ?? null);
|
||||||
|
|
||||||
|
expect(storedIssue?.title).toBe("repo triage for paperclip");
|
||||||
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
|
expect(storedIssue?.description).toBe("Review paperclip for high bugs");
|
||||||
expect(storedRun?.triggerPayload).toEqual({
|
expect(storedRun?.triggerPayload).toEqual({
|
||||||
variables: {
|
variables: {
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||||
await fs.mkdir(stalePackageDir, { recursive: true });
|
await fs.mkdir(stalePackageDir, { recursive: true });
|
||||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links\n", "utf8");
|
||||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(repoRoot, "server", "package.json"),
|
path.join(repoRoot, "server", "package.json"),
|
||||||
|
|
@ -235,6 +236,7 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||||
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||||
await fs.mkdir(expectedPackageDir, { recursive: true });
|
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||||
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(repoRoot, ".git"), "gitdir: /tmp/paperclip-main/.git/worktrees/runtime-links-current\n", "utf8");
|
||||||
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(repoRoot, "server", "package.json"),
|
path.join(repoRoot, "server", "package.json"),
|
||||||
|
|
@ -255,6 +257,45 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||||
|
|
||||||
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips relinking outside linked git worktrees", async () => {
|
||||||
|
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-"));
|
||||||
|
const staleRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-links-non-worktree-stale-"));
|
||||||
|
const serverNodeModulesScopeDir = path.join(repoRoot, "server", "node_modules", "@paperclipai");
|
||||||
|
const expectedPackageDir = path.join(repoRoot, "packages", "db");
|
||||||
|
const stalePackageDir = path.join(staleRoot, "db");
|
||||||
|
|
||||||
|
await fs.mkdir(path.join(repoRoot, ".git"), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(repoRoot, "server"), { recursive: true });
|
||||||
|
await fs.mkdir(expectedPackageDir, { recursive: true });
|
||||||
|
await fs.mkdir(stalePackageDir, { recursive: true });
|
||||||
|
await fs.mkdir(serverNodeModulesScopeDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(repoRoot, "pnpm-workspace.yaml"), "packages:\n - packages/*\n - server\n", "utf8");
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(repoRoot, "server", "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "@paperclipai/server",
|
||||||
|
dependencies: {
|
||||||
|
"@paperclipai/db": "workspace:*",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(expectedPackageDir, "package.json"),
|
||||||
|
JSON.stringify({ name: "@paperclipai/db" }),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(stalePackageDir, "package.json"),
|
||||||
|
JSON.stringify({ name: "@paperclipai/db" }),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
await fs.symlink(stalePackageDir, path.join(serverNodeModulesScopeDir, "db"));
|
||||||
|
|
||||||
|
await ensureServerWorkspaceLinksCurrent(path.join(repoRoot, "server"));
|
||||||
|
expect(await fs.realpath(path.join(serverNodeModulesScopeDir, "db"))).toBe(await fs.realpath(stalePackageDir));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("realizeExecutionWorkspace", () => {
|
describe("realizeExecutionWorkspace", () => {
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import { costRoutes } from "./routes/costs.js";
|
||||||
import { activityRoutes } from "./routes/activity.js";
|
import { activityRoutes } from "./routes/activity.js";
|
||||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||||
|
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||||
import { llmRoutes } from "./routes/llms.js";
|
import { llmRoutes } from "./routes/llms.js";
|
||||||
import { assetRoutes } from "./routes/assets.js";
|
import { assetRoutes } from "./routes/assets.js";
|
||||||
|
|
@ -166,6 +167,7 @@ export async function createApp(
|
||||||
api.use(activityRoutes(db));
|
api.use(activityRoutes(db));
|
||||||
api.use(dashboardRoutes(db));
|
api.use(dashboardRoutes(db));
|
||||||
api.use(sidebarBadgeRoutes(db));
|
api.use(sidebarBadgeRoutes(db));
|
||||||
|
api.use(inboxDismissalRoutes(db));
|
||||||
api.use(instanceSettingsRoutes(db));
|
api.use(instanceSettingsRoutes(db));
|
||||||
const hostServicesDisposers = new Map<string, () => void>();
|
const hostServicesDisposers = new Map<string, () => void>();
|
||||||
const workerManager = createPluginWorkerManager();
|
const workerManager = createPluginWorkerManager();
|
||||||
|
|
|
||||||
|
|
@ -449,11 +449,25 @@ export function agentRoutes(db: Db) {
|
||||||
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
|
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
|
||||||
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
|
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
|
||||||
return {
|
return {
|
||||||
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
|
enabled: parseBooleanLike(heartbeat.enabled) ?? false,
|
||||||
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
|
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeNewAgentRuntimeConfig(runtimeConfig: unknown): Record<string, unknown> {
|
||||||
|
const parsedRuntimeConfig = asRecord(runtimeConfig);
|
||||||
|
const normalizedRuntimeConfig = parsedRuntimeConfig ? { ...parsedRuntimeConfig } : {};
|
||||||
|
const parsedHeartbeat = asRecord(normalizedRuntimeConfig.heartbeat);
|
||||||
|
const heartbeat = parsedHeartbeat ? { ...parsedHeartbeat } : {};
|
||||||
|
|
||||||
|
if (parseBooleanLike(heartbeat.enabled) == null) {
|
||||||
|
heartbeat.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedRuntimeConfig.heartbeat = heartbeat;
|
||||||
|
return normalizedRuntimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
function generateEd25519PrivateKeyPem(): string {
|
function generateEd25519PrivateKeyPem(): string {
|
||||||
const { privateKey } = generateKeyPairSync("ed25519");
|
const { privateKey } = generateKeyPairSync("ed25519");
|
||||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||||
|
|
@ -1308,6 +1322,7 @@ export function agentRoutes(db: Db) {
|
||||||
const normalizedHireInput = {
|
const normalizedHireInput = {
|
||||||
...hireInput,
|
...hireInput,
|
||||||
adapterConfig: normalizedAdapterConfig,
|
adapterConfig: normalizedAdapterConfig,
|
||||||
|
runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig),
|
||||||
};
|
};
|
||||||
|
|
||||||
const company = await db
|
const company = await db
|
||||||
|
|
@ -1474,6 +1489,7 @@ export function agentRoutes(db: Db) {
|
||||||
const createdAgent = await svc.create(companyId, {
|
const createdAgent = await svc.create(companyId, {
|
||||||
...createInput,
|
...createInput,
|
||||||
adapterConfig: normalizedAdapterConfig,
|
adapterConfig: normalizedAdapterConfig,
|
||||||
|
runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig),
|
||||||
status: "idle",
|
status: "idle",
|
||||||
spentMonthlyCents: 0,
|
spentMonthlyCents: 0,
|
||||||
lastHeartbeatAt: null,
|
lastHeartbeatAt: null,
|
||||||
|
|
|
||||||
69
server/src/routes/inbox-dismissals.ts
Normal file
69
server/src/routes/inbox-dismissals.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { validate } from "../middleware/validate.js";
|
||||||
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||||
|
import { inboxDismissalService, logActivity } from "../services/index.js";
|
||||||
|
|
||||||
|
const inboxDismissalSchema = z.object({
|
||||||
|
itemKey: z.string().trim().min(1).regex(/^(approval|join|run):.+$/, "Unsupported inbox item key"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function inboxDismissalRoutes(db: Db) {
|
||||||
|
const router = Router();
|
||||||
|
const svc = inboxDismissalService(db);
|
||||||
|
|
||||||
|
router.get("/companies/:companyId/inbox-dismissals", async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Board authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!req.actor.userId) {
|
||||||
|
res.status(403).json({ error: "Board user context required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dismissals = await svc.list(companyId, req.actor.userId);
|
||||||
|
res.json(dismissals);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/companies/:companyId/inbox-dismissals",
|
||||||
|
validate(inboxDismissalSchema),
|
||||||
|
async (req, res) => {
|
||||||
|
const companyId = req.params.companyId as string;
|
||||||
|
assertCompanyAccess(req, companyId);
|
||||||
|
if (req.actor.type !== "board") {
|
||||||
|
res.status(403).json({ error: "Board authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!req.actor.userId) {
|
||||||
|
res.status(403).json({ error: "Board user context required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissal = await svc.dismiss(companyId, req.actor.userId, req.body.itemKey, new Date());
|
||||||
|
const actor = getActorInfo(req);
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "inbox.dismissed",
|
||||||
|
entityType: "company",
|
||||||
|
entityId: companyId,
|
||||||
|
details: {
|
||||||
|
userId: req.actor.userId,
|
||||||
|
itemKey: dismissal.itemKey,
|
||||||
|
dismissedAt: dismissal.dismissedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json(dismissal);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export { costRoutes } from "./costs.js";
|
||||||
export { activityRoutes } from "./activity.js";
|
export { activityRoutes } from "./activity.js";
|
||||||
export { dashboardRoutes } from "./dashboard.js";
|
export { dashboardRoutes } from "./dashboard.js";
|
||||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||||
|
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
|
||||||
export { llmRoutes } from "./llms.js";
|
export { llmRoutes } from "./llms.js";
|
||||||
export { accessRoutes } from "./access.js";
|
export { accessRoutes } from "./access.js";
|
||||||
export { instanceSettingsRoutes } from "./instance-settings.js";
|
export { instanceSettingsRoutes } from "./instance-settings.js";
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,219 @@ import {
|
||||||
SVG_CONTENT_TYPE,
|
SVG_CONTENT_TYPE,
|
||||||
} from "../attachment-types.js";
|
} from "../attachment-types.js";
|
||||||
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
||||||
import { applyIssueExecutionPolicyTransition, normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
|
import {
|
||||||
|
applyIssueExecutionPolicyTransition,
|
||||||
|
normalizeIssueExecutionPolicy,
|
||||||
|
parseIssueExecutionState,
|
||||||
|
} from "../services/issue-execution-policy.js";
|
||||||
|
|
||||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||||
const updateIssueRouteSchema = updateIssueSchema.extend({
|
const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||||
interrupt: z.boolean().optional(),
|
interrupt: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
|
||||||
|
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
|
||||||
|
type ActivityIssueRelationSummary = {
|
||||||
|
id: string;
|
||||||
|
identifier: string | null;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
type ActivityExecutionParticipant = Pick<
|
||||||
|
NormalizedExecutionPolicy["stages"][number]["participants"][number],
|
||||||
|
"type" | "agentId" | "userId"
|
||||||
|
>;
|
||||||
|
type ExecutionStageWakeContext = {
|
||||||
|
wakeRole: "reviewer" | "approver" | "executor";
|
||||||
|
stageId: string | null;
|
||||||
|
stageType: ParsedExecutionState["currentStageType"];
|
||||||
|
currentParticipant: ParsedExecutionState["currentParticipant"];
|
||||||
|
returnAssignee: ParsedExecutionState["returnAssignee"];
|
||||||
|
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
|
||||||
|
allowedActions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function executionPrincipalsEqual(
|
||||||
|
left: ParsedExecutionState["currentParticipant"] | null,
|
||||||
|
right: ParsedExecutionState["currentParticipant"] | null,
|
||||||
|
) {
|
||||||
|
if (!left || !right || left.type !== right.type) return false;
|
||||||
|
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executionParticipantMatchesAgent(
|
||||||
|
participant: ParsedExecutionState["currentParticipant"] | null,
|
||||||
|
agentId: string | null | undefined,
|
||||||
|
) {
|
||||||
|
return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecutionStageWakeContext(input: {
|
||||||
|
state: ParsedExecutionState;
|
||||||
|
wakeRole: ExecutionStageWakeContext["wakeRole"];
|
||||||
|
allowedActions: string[];
|
||||||
|
}): ExecutionStageWakeContext {
|
||||||
|
return {
|
||||||
|
wakeRole: input.wakeRole,
|
||||||
|
stageId: input.state.currentStageId,
|
||||||
|
stageType: input.state.currentStageType,
|
||||||
|
currentParticipant: input.state.currentParticipant,
|
||||||
|
returnAssignee: input.state.returnAssignee,
|
||||||
|
lastDecisionOutcome: input.state.lastDecisionOutcome,
|
||||||
|
allowedActions: input.allowedActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeIssueRelationForActivity(relation: {
|
||||||
|
id: string;
|
||||||
|
identifier: string | null;
|
||||||
|
title: string;
|
||||||
|
}): ActivityIssueRelationSummary {
|
||||||
|
return {
|
||||||
|
id: relation.id,
|
||||||
|
identifier: relation.identifier,
|
||||||
|
title: relation.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
|
||||||
|
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeExecutionParticipants(
|
||||||
|
policy: NormalizedExecutionPolicy | null,
|
||||||
|
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
|
||||||
|
): ActivityExecutionParticipant[] {
|
||||||
|
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
|
||||||
|
return (
|
||||||
|
stage?.participants.map((participant) => ({
|
||||||
|
type: participant.type,
|
||||||
|
agentId: participant.agentId ?? null,
|
||||||
|
userId: participant.userId ?? null,
|
||||||
|
})) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffExecutionParticipants(
|
||||||
|
previousPolicy: NormalizedExecutionPolicy | null,
|
||||||
|
nextPolicy: NormalizedExecutionPolicy | null,
|
||||||
|
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
|
||||||
|
) {
|
||||||
|
const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType);
|
||||||
|
const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType);
|
||||||
|
const previousByKey = new Map(previousParticipants.map((participant) => [
|
||||||
|
activityExecutionParticipantKey(participant),
|
||||||
|
participant,
|
||||||
|
]));
|
||||||
|
const nextByKey = new Map(nextParticipants.map((participant) => [
|
||||||
|
activityExecutionParticipantKey(participant),
|
||||||
|
participant,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
participants: nextParticipants,
|
||||||
|
addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))),
|
||||||
|
removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecutionStageWakeup(input: {
|
||||||
|
issueId: string;
|
||||||
|
previousState: ParsedExecutionState | null;
|
||||||
|
nextState: ParsedExecutionState | null;
|
||||||
|
interruptedRunId: string | null;
|
||||||
|
requestedByActorType: "user" | "agent";
|
||||||
|
requestedByActorId: string;
|
||||||
|
}) {
|
||||||
|
const { issueId, previousState, nextState, interruptedRunId } = input;
|
||||||
|
if (!nextState) return null;
|
||||||
|
|
||||||
|
if (nextState.status === "pending") {
|
||||||
|
const agentId =
|
||||||
|
nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null;
|
||||||
|
const stageChanged =
|
||||||
|
previousState?.status !== "pending" ||
|
||||||
|
previousState?.currentStageId !== nextState.currentStageId ||
|
||||||
|
!executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null);
|
||||||
|
if (!agentId || !stageChanged) return null;
|
||||||
|
|
||||||
|
const reason =
|
||||||
|
nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested";
|
||||||
|
const executionStage = buildExecutionStageWakeContext({
|
||||||
|
state: nextState,
|
||||||
|
wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer",
|
||||||
|
allowedActions: ["approve", "request_changes"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
wakeup: {
|
||||||
|
source: "assignment" as const,
|
||||||
|
triggerDetail: "system" as const,
|
||||||
|
reason,
|
||||||
|
payload: {
|
||||||
|
issueId,
|
||||||
|
mutation: "update",
|
||||||
|
executionStage,
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
|
},
|
||||||
|
requestedByActorType: input.requestedByActorType,
|
||||||
|
requestedByActorId: input.requestedByActorId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId,
|
||||||
|
taskId: issueId,
|
||||||
|
wakeReason: reason,
|
||||||
|
source: "issue.execution_stage",
|
||||||
|
executionStage,
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextState.status === "changes_requested") {
|
||||||
|
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
|
||||||
|
const becameChangesRequested =
|
||||||
|
previousState?.status !== "changes_requested" ||
|
||||||
|
previousState?.lastDecisionId !== nextState.lastDecisionId ||
|
||||||
|
!executionPrincipalsEqual(previousState?.returnAssignee ?? null, nextState.returnAssignee ?? null);
|
||||||
|
if (!agentId || !becameChangesRequested) return null;
|
||||||
|
|
||||||
|
const executionStage = buildExecutionStageWakeContext({
|
||||||
|
state: nextState,
|
||||||
|
wakeRole: "executor",
|
||||||
|
allowedActions: ["address_changes", "resubmit"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentId,
|
||||||
|
wakeup: {
|
||||||
|
source: "assignment" as const,
|
||||||
|
triggerDetail: "system" as const,
|
||||||
|
reason: "execution_changes_requested",
|
||||||
|
payload: {
|
||||||
|
issueId,
|
||||||
|
mutation: "update",
|
||||||
|
executionStage,
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
|
},
|
||||||
|
requestedByActorType: input.requestedByActorType,
|
||||||
|
requestedByActorId: input.requestedByActorId,
|
||||||
|
contextSnapshot: {
|
||||||
|
issueId,
|
||||||
|
taskId: issueId,
|
||||||
|
wakeReason: "execution_changes_requested",
|
||||||
|
source: "issue.execution_stage",
|
||||||
|
executionStage,
|
||||||
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function issueRoutes(
|
export function issueRoutes(
|
||||||
db: Db,
|
db: Db,
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
|
|
@ -1066,9 +1272,10 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
|
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||||
const issue = await svc.create(companyId, {
|
const issue = await svc.create(companyId, {
|
||||||
...req.body,
|
...req.body,
|
||||||
executionPolicy: normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
executionPolicy,
|
||||||
createdByAgentId: actor.agentId,
|
createdByAgentId: actor.agentId,
|
||||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||||
});
|
});
|
||||||
|
|
@ -1110,24 +1317,6 @@ export function issueRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assertCompanyAccess(req, existing.companyId);
|
assertCompanyAccess(req, existing.companyId);
|
||||||
const assigneeWillChange =
|
|
||||||
(req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId) ||
|
|
||||||
(req.body.assigneeUserId !== undefined && req.body.assigneeUserId !== existing.assigneeUserId);
|
|
||||||
|
|
||||||
const isAgentReturningIssueToCreator =
|
|
||||||
req.actor.type === "agent" &&
|
|
||||||
!!req.actor.agentId &&
|
|
||||||
existing.assigneeAgentId === req.actor.agentId &&
|
|
||||||
req.body.assigneeAgentId === null &&
|
|
||||||
typeof req.body.assigneeUserId === "string" &&
|
|
||||||
!!existing.createdByUserId &&
|
|
||||||
req.body.assigneeUserId === existing.createdByUserId;
|
|
||||||
|
|
||||||
if (assigneeWillChange) {
|
|
||||||
if (!isAgentReturningIssueToCreator) {
|
|
||||||
await assertCanAssignTasks(req, existing.companyId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return;
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
|
|
@ -1191,14 +1380,20 @@ export function issueRoutes(
|
||||||
if (req.body.executionPolicy !== undefined) {
|
if (req.body.executionPolicy !== undefined) {
|
||||||
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||||
}
|
}
|
||||||
|
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
|
||||||
|
const nextExecutionPolicy =
|
||||||
|
updateFields.executionPolicy !== undefined
|
||||||
|
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
|
||||||
|
: previousExecutionPolicy;
|
||||||
|
|
||||||
|
const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined;
|
||||||
|
const requestedAssigneePatchProvided =
|
||||||
|
req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined;
|
||||||
|
|
||||||
const transition = applyIssueExecutionPolicyTransition({
|
const transition = applyIssueExecutionPolicyTransition({
|
||||||
issue: existing,
|
issue: existing,
|
||||||
policy:
|
policy: nextExecutionPolicy,
|
||||||
updateFields.executionPolicy !== undefined
|
requestedStatus,
|
||||||
? (updateFields.executionPolicy as NonNullable<typeof updateFields.executionPolicy> | null)
|
|
||||||
: normalizeIssueExecutionPolicy(existing.executionPolicy ?? null),
|
|
||||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
|
||||||
requestedAssigneePatch: {
|
requestedAssigneePatch: {
|
||||||
assigneeAgentId:
|
assigneeAgentId:
|
||||||
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
||||||
|
|
@ -1224,6 +1419,48 @@ export function issueRoutes(
|
||||||
}
|
}
|
||||||
Object.assign(updateFields, transition.patch);
|
Object.assign(updateFields, transition.patch);
|
||||||
|
|
||||||
|
const effectiveExecutionState = parseIssueExecutionState(
|
||||||
|
transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState,
|
||||||
|
);
|
||||||
|
const isUnauthorizedAgentStageMutation =
|
||||||
|
req.actor.type === "agent" &&
|
||||||
|
req.actor.agentId &&
|
||||||
|
existing.status === "in_review" &&
|
||||||
|
transition.workflowControlledAssignment &&
|
||||||
|
!transition.decision &&
|
||||||
|
effectiveExecutionState?.status === "pending" &&
|
||||||
|
(
|
||||||
|
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
||||||
|
requestedAssigneePatchProvided
|
||||||
|
) &&
|
||||||
|
!executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId);
|
||||||
|
if (isUnauthorizedAgentStageMutation) {
|
||||||
|
const stageLabel = effectiveExecutionState.currentStageType ?? "execution";
|
||||||
|
res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAssigneeAgentId =
|
||||||
|
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
|
||||||
|
const nextAssigneeUserId =
|
||||||
|
updateFields.assigneeUserId === undefined ? existing.assigneeUserId : (updateFields.assigneeUserId as string | null);
|
||||||
|
const assigneeWillChange =
|
||||||
|
nextAssigneeAgentId !== existing.assigneeAgentId || nextAssigneeUserId !== existing.assigneeUserId;
|
||||||
|
const isAgentReturningIssueToCreator =
|
||||||
|
req.actor.type === "agent" &&
|
||||||
|
!!req.actor.agentId &&
|
||||||
|
existing.assigneeAgentId === req.actor.agentId &&
|
||||||
|
nextAssigneeAgentId === null &&
|
||||||
|
typeof nextAssigneeUserId === "string" &&
|
||||||
|
!!existing.createdByUserId &&
|
||||||
|
nextAssigneeUserId === existing.createdByUserId;
|
||||||
|
|
||||||
|
if (assigneeWillChange && !transition.workflowControlledAssignment) {
|
||||||
|
if (!isAgentReturningIssueToCreator) {
|
||||||
|
await assertCanAssignTasks(req, existing.companyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let issue;
|
let issue;
|
||||||
try {
|
try {
|
||||||
if (transition.decision && decisionId) {
|
if (transition.decision && decisionId) {
|
||||||
|
|
@ -1291,8 +1528,9 @@ export function issueRoutes(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
|
let issueResponse: typeof issue & { blockedBy?: unknown; blocks?: unknown } = issue;
|
||||||
|
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
|
||||||
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
||||||
const updatedRelations = await svc.getRelationSummaries(issue.id);
|
updatedRelations = await svc.getRelationSummaries(issue.id);
|
||||||
issueResponse = {
|
issueResponse = {
|
||||||
...issue,
|
...issue,
|
||||||
blockedBy: updatedRelations.blockedBy,
|
blockedBy: updatedRelations.blockedBy,
|
||||||
|
|
@ -1349,6 +1587,8 @@ export function issueRoutes(
|
||||||
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
|
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
|
||||||
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
|
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
|
||||||
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
|
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
|
||||||
|
const nextBlockedByRelations = updatedRelations?.blockedBy ?? [];
|
||||||
|
const previousBlockedByRelations = existingRelations?.blockedBy ?? [];
|
||||||
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
|
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
|
|
@ -1364,11 +1604,58 @@ export function issueRoutes(
|
||||||
blockedByIssueIds: req.body.blockedByIssueIds,
|
blockedByIssueIds: req.body.blockedByIssueIds,
|
||||||
addedBlockedByIssueIds,
|
addedBlockedByIssueIds,
|
||||||
removedBlockedByIssueIds,
|
removedBlockedByIssueIds,
|
||||||
|
blockedByIssues: nextBlockedByRelations.map(summarizeIssueRelationForActivity),
|
||||||
|
addedBlockedByIssues: nextBlockedByRelations
|
||||||
|
.filter((relation) => addedBlockedByIssueIds.includes(relation.id))
|
||||||
|
.map(summarizeIssueRelationForActivity),
|
||||||
|
removedBlockedByIssues: previousBlockedByRelations
|
||||||
|
.filter((relation) => removedBlockedByIssueIds.includes(relation.id))
|
||||||
|
.map(summarizeIssueRelationForActivity),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reviewerChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "review");
|
||||||
|
if (reviewerChanges.addedParticipants.length > 0 || reviewerChanges.removedParticipants.length > 0) {
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.reviewers_updated",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
participants: reviewerChanges.participants,
|
||||||
|
addedParticipants: reviewerChanges.addedParticipants,
|
||||||
|
removedParticipants: reviewerChanges.removedParticipants,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const approverChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "approval");
|
||||||
|
if (approverChanges.addedParticipants.length > 0 || approverChanges.removedParticipants.length > 0) {
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: issue.companyId,
|
||||||
|
actorType: actor.actorType,
|
||||||
|
actorId: actor.actorId,
|
||||||
|
agentId: actor.agentId,
|
||||||
|
runId: actor.runId,
|
||||||
|
action: "issue.approvers_updated",
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: issue.id,
|
||||||
|
details: {
|
||||||
|
identifier: issue.identifier,
|
||||||
|
participants: approverChanges.participants,
|
||||||
|
addedParticipants: approverChanges.addedParticipants,
|
||||||
|
removedParticipants: approverChanges.removedParticipants,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (issue.status === "done" && existing.status !== "done") {
|
if (issue.status === "done" && existing.status !== "done") {
|
||||||
const tc = getTelemetryClient();
|
const tc = getTelemetryClient();
|
||||||
if (tc && actor.agentId) {
|
if (tc && actor.agentId) {
|
||||||
|
|
@ -1414,6 +1701,16 @@ export function issueRoutes(
|
||||||
existing.status === "backlog" &&
|
existing.status === "backlog" &&
|
||||||
issue.status !== "backlog" &&
|
issue.status !== "backlog" &&
|
||||||
req.body.status !== undefined;
|
req.body.status !== undefined;
|
||||||
|
const previousExecutionState = parseIssueExecutionState(existing.executionState);
|
||||||
|
const nextExecutionState = parseIssueExecutionState(issue.executionState);
|
||||||
|
const executionStageWakeup = buildExecutionStageWakeup({
|
||||||
|
issueId: issue.id,
|
||||||
|
previousState: previousExecutionState,
|
||||||
|
nextState: nextExecutionState,
|
||||||
|
interruptedRunId,
|
||||||
|
requestedByActorType: actor.actorType,
|
||||||
|
requestedByActorId: actor.actorId,
|
||||||
|
});
|
||||||
|
|
||||||
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -1427,7 +1724,9 @@ export function issueRoutes(
|
||||||
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
|
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
if (executionStageWakeup) {
|
||||||
|
addWakeup(executionStageWakeup.agentId, executionStageWakeup.wakeup);
|
||||||
|
} else if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
||||||
addWakeup(issue.assigneeAgentId, {
|
addWakeup(issue.assigneeAgentId, {
|
||||||
source: "assignment",
|
source: "assignment",
|
||||||
triggerDetail: "system",
|
triggerDetail: "system",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { joinRequests } from "@paperclipai/db";
|
import { inboxDismissals, joinRequests } from "@paperclipai/db";
|
||||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||||
import { accessService } from "../services/access.js";
|
import { accessService } from "../services/access.js";
|
||||||
import { dashboardService } from "../services/dashboard.js";
|
import { dashboardService } from "../services/dashboard.js";
|
||||||
import { assertCompanyAccess } from "./authz.js";
|
import { assertCompanyAccess } from "./authz.js";
|
||||||
|
|
||||||
|
function buildDismissedAtByKey(
|
||||||
|
dismissals: Array<{ itemKey: string; dismissedAt: Date | string }>,
|
||||||
|
): Map<string, number> {
|
||||||
|
return new Map(
|
||||||
|
dismissals.map((dismissal) => [dismissal.itemKey, new Date(dismissal.dismissedAt).getTime()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function sidebarBadgeRoutes(db: Db) {
|
export function sidebarBadgeRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = sidebarBadgeService(db);
|
const svc = sidebarBadgeService(db);
|
||||||
|
|
@ -26,23 +34,36 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||||
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
|
canApproveJoins = await access.hasPermission(companyId, "agent", req.actor.agentId, "joins:approve");
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinRequestCount = canApproveJoins
|
const visibleJoinRequests = canApproveJoins
|
||||||
? await db
|
? await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({
|
||||||
|
id: joinRequests.id,
|
||||||
|
updatedAt: joinRequests.updatedAt,
|
||||||
|
createdAt: joinRequests.createdAt,
|
||||||
|
})
|
||||||
.from(joinRequests)
|
.from(joinRequests)
|
||||||
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
.where(and(eq(joinRequests.companyId, companyId), eq(joinRequests.status, "pending_approval")))
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
: [];
|
||||||
: 0;
|
|
||||||
|
const dismissedAtByKey =
|
||||||
|
req.actor.type === "board" && req.actor.userId
|
||||||
|
? await db
|
||||||
|
.select({ itemKey: inboxDismissals.itemKey, dismissedAt: inboxDismissals.dismissedAt })
|
||||||
|
.from(inboxDismissals)
|
||||||
|
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, req.actor.userId)))
|
||||||
|
.then(buildDismissedAtByKey)
|
||||||
|
: new Map<string, number>();
|
||||||
|
|
||||||
const badges = await svc.get(companyId, {
|
const badges = await svc.get(companyId, {
|
||||||
joinRequests: joinRequestCount,
|
dismissals: dismissedAtByKey,
|
||||||
|
joinRequests: visibleJoinRequests,
|
||||||
});
|
});
|
||||||
const summary = await dashboard.summary(companyId);
|
const summary = await dashboard.summary(companyId);
|
||||||
const hasFailedRuns = badges.failedRuns > 0;
|
const hasFailedRuns = badges.failedRuns > 0;
|
||||||
const alertsCount =
|
const alertsCount =
|
||||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||||
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
badges.inbox = badges.failedRuns + alertsCount + badges.joinRequests + badges.approvals;
|
||||||
|
|
||||||
res.json(badges);
|
res.json(badges);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -696,7 +696,14 @@ export function shouldResetTaskSessionForWake(
|
||||||
if (contextSnapshot?.forceFreshSession === true) return true;
|
if (contextSnapshot?.forceFreshSession === true) return true;
|
||||||
|
|
||||||
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
||||||
if (wakeReason === "issue_assigned") return true;
|
if (
|
||||||
|
wakeReason === "issue_assigned" ||
|
||||||
|
wakeReason === "execution_review_requested" ||
|
||||||
|
wakeReason === "execution_approval_requested" ||
|
||||||
|
wakeReason === "execution_changes_requested"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -714,6 +721,9 @@ function describeSessionResetReason(
|
||||||
|
|
||||||
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
||||||
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
|
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
|
||||||
|
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
|
||||||
|
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
|
||||||
|
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -867,9 +877,8 @@ async function buildPaperclipWakePayload(input: {
|
||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
}) {
|
}) {
|
||||||
|
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
||||||
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||||
if (commentIds.length === 0) return null;
|
|
||||||
|
|
||||||
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
|
||||||
const issueSummary =
|
const issueSummary =
|
||||||
input.issueSummary ??
|
input.issueSummary ??
|
||||||
|
|
@ -886,23 +895,27 @@ async function buildPaperclipWakePayload(input: {
|
||||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId)))
|
||||||
.then((rows) => rows[0] ?? null)
|
.then((rows) => rows[0] ?? null)
|
||||||
: null);
|
: null);
|
||||||
|
if (commentIds.length === 0 && Object.keys(executionStage).length === 0 && !issueSummary) return null;
|
||||||
|
|
||||||
const commentRows = await input.db
|
const commentRows =
|
||||||
.select({
|
commentIds.length === 0
|
||||||
id: issueComments.id,
|
? []
|
||||||
issueId: issueComments.issueId,
|
: await input.db
|
||||||
body: issueComments.body,
|
.select({
|
||||||
authorAgentId: issueComments.authorAgentId,
|
id: issueComments.id,
|
||||||
authorUserId: issueComments.authorUserId,
|
issueId: issueComments.issueId,
|
||||||
createdAt: issueComments.createdAt,
|
body: issueComments.body,
|
||||||
})
|
authorAgentId: issueComments.authorAgentId,
|
||||||
.from(issueComments)
|
authorUserId: issueComments.authorUserId,
|
||||||
.where(
|
createdAt: issueComments.createdAt,
|
||||||
and(
|
})
|
||||||
eq(issueComments.companyId, input.companyId),
|
.from(issueComments)
|
||||||
inArray(issueComments.id, commentIds),
|
.where(
|
||||||
),
|
and(
|
||||||
);
|
eq(issueComments.companyId, input.companyId),
|
||||||
|
inArray(issueComments.id, commentIds),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
const commentsById = new Map(commentRows.map((comment) => [comment.id, comment]));
|
||||||
const comments: Array<Record<string, unknown>> = [];
|
const comments: Array<Record<string, unknown>> = [];
|
||||||
|
|
@ -959,6 +972,7 @@ async function buildPaperclipWakePayload(input: {
|
||||||
priority: issueSummary.priority,
|
priority: issueSummary.priority,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
||||||
commentIds,
|
commentIds,
|
||||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||||
comments,
|
comments,
|
||||||
|
|
@ -2159,7 +2173,7 @@ export function heartbeatService(db: Db) {
|
||||||
const heartbeat = parseObject(runtimeConfig.heartbeat);
|
const heartbeat = parseObject(runtimeConfig.heartbeat);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: asBoolean(heartbeat.enabled, true),
|
enabled: asBoolean(heartbeat.enabled, false),
|
||||||
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
|
intervalSec: Math.max(0, asNumber(heartbeat.intervalSec, 0)),
|
||||||
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
|
wakeOnDemand: asBoolean(heartbeat.wakeOnDemand ?? heartbeat.wakeOnAssignment ?? heartbeat.wakeOnOnDemand ?? heartbeat.wakeOnAutomation, true),
|
||||||
maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns),
|
maxConcurrentRuns: normalizeMaxConcurrentRuns(heartbeat.maxConcurrentRuns),
|
||||||
|
|
|
||||||
41
server/src/services/inbox-dismissals.ts
Normal file
41
server/src/services/inbox-dismissals.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
import type { Db } from "@paperclipai/db";
|
||||||
|
import { inboxDismissals } from "@paperclipai/db";
|
||||||
|
|
||||||
|
export function inboxDismissalService(db: Db) {
|
||||||
|
return {
|
||||||
|
list: async (companyId: string, userId: string) =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(inboxDismissals)
|
||||||
|
.where(and(eq(inboxDismissals.companyId, companyId), eq(inboxDismissals.userId, userId)))
|
||||||
|
.orderBy(desc(inboxDismissals.updatedAt)),
|
||||||
|
|
||||||
|
dismiss: async (
|
||||||
|
companyId: string,
|
||||||
|
userId: string,
|
||||||
|
itemKey: string,
|
||||||
|
dismissedAt: Date = new Date(),
|
||||||
|
) => {
|
||||||
|
const now = new Date();
|
||||||
|
const [row] = await db
|
||||||
|
.insert(inboxDismissals)
|
||||||
|
.values({
|
||||||
|
companyId,
|
||||||
|
userId,
|
||||||
|
itemKey,
|
||||||
|
dismissedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [inboxDismissals.companyId, inboxDismissals.userId, inboxDismissals.itemKey],
|
||||||
|
set: {
|
||||||
|
dismissedAt,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ export { financeService } from "./finance.js";
|
||||||
export { heartbeatService } from "./heartbeat.js";
|
export { heartbeatService } from "./heartbeat.js";
|
||||||
export { dashboardService } from "./dashboard.js";
|
export { dashboardService } from "./dashboard.js";
|
||||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||||
|
export { inboxDismissalService } from "./inbox-dismissals.js";
|
||||||
export { accessService } from "./access.js";
|
export { accessService } from "./access.js";
|
||||||
export { boardAuthService } from "./board-auth.js";
|
export { boardAuthService } from "./board-auth.js";
|
||||||
export { instanceSettingsService } from "./instance-settings.js";
|
export { instanceSettingsService } from "./instance-settings.js";
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type TransitionInput = {
|
||||||
type TransitionResult = {
|
type TransitionResult = {
|
||||||
patch: Record<string, unknown>;
|
patch: Record<string, unknown>;
|
||||||
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
|
decision?: Pick<IssueExecutionDecision, "stageId" | "stageType" | "outcome" | "body">;
|
||||||
|
workflowControlledAssignment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
||||||
|
|
@ -144,6 +145,11 @@ function selectStageParticipant(
|
||||||
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
|
return first ? { type: first.type, agentId: first.agentId ?? null, userId: first.userId ?? null } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stageHasParticipant(stage: IssueExecutionStage, participant: IssueExecutionStagePrincipal | null): boolean {
|
||||||
|
if (!participant) return false;
|
||||||
|
return stage.participants.some((candidate) => principalsEqual(candidate, participant));
|
||||||
|
}
|
||||||
|
|
||||||
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
|
function patchForPrincipal(principal: IssueExecutionStagePrincipal | null) {
|
||||||
if (!principal) {
|
if (!principal) {
|
||||||
return { assigneeAgentId: null, assigneeUserId: null };
|
return { assigneeAgentId: null, assigneeUserId: null };
|
||||||
|
|
@ -198,14 +204,49 @@ function buildChangesRequestedState(previous: IssueExecutionState, currentStage:
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPendingStagePatch(input: {
|
||||||
|
patch: Record<string, unknown>;
|
||||||
|
previous: IssueExecutionState | null;
|
||||||
|
policy: IssueExecutionPolicy;
|
||||||
|
stage: IssueExecutionStage;
|
||||||
|
participant: IssueExecutionStagePrincipal;
|
||||||
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||||
|
}) {
|
||||||
|
input.patch.status = "in_review";
|
||||||
|
Object.assign(input.patch, patchForPrincipal(input.participant));
|
||||||
|
input.patch.executionState = buildPendingState({
|
||||||
|
previous: input.previous,
|
||||||
|
stage: input.stage,
|
||||||
|
stageIndex: input.policy.stages.findIndex((candidate) => candidate.id === input.stage.id),
|
||||||
|
participant: input.participant,
|
||||||
|
returnAssignee: input.returnAssignee,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExecutionStatePatch(input: {
|
||||||
|
patch: Record<string, unknown>;
|
||||||
|
issueStatus: string;
|
||||||
|
requestedStatus?: string;
|
||||||
|
returnAssignee: IssueExecutionStagePrincipal | null;
|
||||||
|
}) {
|
||||||
|
input.patch.executionState = null;
|
||||||
|
if (input.requestedStatus === undefined && input.issueStatus === "in_review" && input.returnAssignee) {
|
||||||
|
input.patch.status = "in_progress";
|
||||||
|
Object.assign(input.patch, patchForPrincipal(input.returnAssignee));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||||
const currentAssignee = assigneePrincipal(input.issue);
|
const currentAssignee = assigneePrincipal(input.issue);
|
||||||
const actor = actorPrincipal(input.actor);
|
const actor = actorPrincipal(input.actor);
|
||||||
|
const requestedAssigneePatchProvided =
|
||||||
|
input.requestedAssigneePatch.assigneeAgentId !== undefined || input.requestedAssigneePatch.assigneeUserId !== undefined;
|
||||||
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
|
const explicitAssignee = assigneePrincipal(input.requestedAssigneePatch);
|
||||||
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
|
const currentStage = input.policy ? findStageById(input.policy, existingState?.currentStageId) : null;
|
||||||
const requestedStatus = input.requestedStatus;
|
const requestedStatus = input.requestedStatus;
|
||||||
|
const activeStage = currentStage && existingState?.status === PENDING_STATUS ? currentStage : null;
|
||||||
|
|
||||||
if (!input.policy) {
|
if (!input.policy) {
|
||||||
if (existingState) {
|
if (existingState) {
|
||||||
|
|
@ -228,90 +269,159 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||||
return { patch };
|
return { patch };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentStage && input.issue.status === "in_review") {
|
if (existingState?.currentStageId && !currentStage) {
|
||||||
if (!principalsEqual(existingState?.currentParticipant ?? null, actor)) {
|
clearExecutionStatePatch({
|
||||||
if (requestedStatus && requestedStatus !== "in_review") {
|
patch,
|
||||||
throw unprocessable("Only the active reviewer or approver can advance the current execution stage");
|
issueStatus: input.issue.status,
|
||||||
}
|
requestedStatus,
|
||||||
return { patch };
|
returnAssignee: existingState.returnAssignee,
|
||||||
|
});
|
||||||
|
return { patch };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeStage) {
|
||||||
|
const currentParticipant =
|
||||||
|
existingState?.currentParticipant ??
|
||||||
|
selectStageParticipant(activeStage, {
|
||||||
|
exclude: existingState?.returnAssignee ?? null,
|
||||||
|
});
|
||||||
|
if (!currentParticipant) {
|
||||||
|
throw unprocessable(`No eligible ${activeStage.type} participant is configured for this issue`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedStatus === "done") {
|
if (!stageHasParticipant(activeStage, currentParticipant)) {
|
||||||
if (!input.commentBody?.trim()) {
|
const participant = selectStageParticipant(activeStage, {
|
||||||
throw unprocessable("Approving a review or approval stage requires a comment");
|
preferred: explicitAssignee ?? existingState?.currentParticipant ?? null,
|
||||||
}
|
|
||||||
const approvedState = buildCompletedState(existingState, currentStage);
|
|
||||||
const nextStage = nextPendingStage(
|
|
||||||
input.policy,
|
|
||||||
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!nextStage) {
|
|
||||||
patch.executionState = approvedState;
|
|
||||||
return {
|
|
||||||
patch,
|
|
||||||
decision: {
|
|
||||||
stageId: currentStage.id,
|
|
||||||
stageType: currentStage.type,
|
|
||||||
outcome: "approved",
|
|
||||||
body: input.commentBody.trim(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const participant = selectStageParticipant(nextStage, {
|
|
||||||
preferred: explicitAssignee,
|
|
||||||
exclude: existingState?.returnAssignee ?? null,
|
exclude: existingState?.returnAssignee ?? null,
|
||||||
});
|
});
|
||||||
if (!participant) {
|
if (!participant) {
|
||||||
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
|
clearExecutionStatePatch({
|
||||||
|
patch,
|
||||||
|
issueStatus: input.issue.status,
|
||||||
|
requestedStatus,
|
||||||
|
returnAssignee: existingState?.returnAssignee ?? null,
|
||||||
|
});
|
||||||
|
return { patch };
|
||||||
}
|
}
|
||||||
|
|
||||||
patch.status = "in_review";
|
buildPendingStagePatch({
|
||||||
Object.assign(patch, patchForPrincipal(participant));
|
patch,
|
||||||
patch.executionState = buildPendingState({
|
previous: existingState,
|
||||||
previous: approvedState,
|
policy: input.policy,
|
||||||
stage: nextStage,
|
stage: activeStage,
|
||||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === nextStage.id),
|
|
||||||
participant,
|
participant,
|
||||||
returnAssignee: existingState?.returnAssignee ?? currentAssignee,
|
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
patch,
|
patch,
|
||||||
decision: {
|
workflowControlledAssignment: true,
|
||||||
stageId: currentStage.id,
|
|
||||||
stageType: currentStage.type,
|
|
||||||
outcome: "approved",
|
|
||||||
body: input.commentBody.trim(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedStatus && requestedStatus !== "in_review") {
|
if (principalsEqual(currentParticipant, actor)) {
|
||||||
if (!input.commentBody?.trim()) {
|
if (requestedStatus === "done") {
|
||||||
throw unprocessable("Requesting changes requires a comment");
|
if (!input.commentBody?.trim()) {
|
||||||
|
throw unprocessable("Approving a review or approval stage requires a comment");
|
||||||
|
}
|
||||||
|
const approvedState = buildCompletedState(existingState, activeStage);
|
||||||
|
const nextStage = nextPendingStage(
|
||||||
|
input.policy,
|
||||||
|
{ ...approvedState, completedStageIds: approvedState.completedStageIds },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!nextStage) {
|
||||||
|
patch.executionState = approvedState;
|
||||||
|
return {
|
||||||
|
patch,
|
||||||
|
decision: {
|
||||||
|
stageId: activeStage.id,
|
||||||
|
stageType: activeStage.type,
|
||||||
|
outcome: "approved",
|
||||||
|
body: input.commentBody.trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const participant = selectStageParticipant(nextStage, {
|
||||||
|
preferred: explicitAssignee,
|
||||||
|
exclude: existingState?.returnAssignee ?? null,
|
||||||
|
});
|
||||||
|
if (!participant) {
|
||||||
|
throw unprocessable(`No eligible ${nextStage.type} participant is configured for this issue`);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPendingStagePatch({
|
||||||
|
patch,
|
||||||
|
previous: approvedState,
|
||||||
|
policy: input.policy,
|
||||||
|
stage: nextStage,
|
||||||
|
participant,
|
||||||
|
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
patch,
|
||||||
|
decision: {
|
||||||
|
stageId: activeStage.id,
|
||||||
|
stageType: activeStage.type,
|
||||||
|
outcome: "approved",
|
||||||
|
body: input.commentBody.trim(),
|
||||||
|
},
|
||||||
|
workflowControlledAssignment: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!existingState?.returnAssignee) {
|
|
||||||
throw unprocessable("This execution stage has no return assignee");
|
if (requestedStatus && requestedStatus !== "in_review") {
|
||||||
|
if (!input.commentBody?.trim()) {
|
||||||
|
throw unprocessable("Requesting changes requires a comment");
|
||||||
|
}
|
||||||
|
if (!existingState?.returnAssignee) {
|
||||||
|
throw unprocessable("This execution stage has no return assignee");
|
||||||
|
}
|
||||||
|
patch.status = "in_progress";
|
||||||
|
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
||||||
|
patch.executionState = buildChangesRequestedState(existingState, activeStage);
|
||||||
|
return {
|
||||||
|
patch,
|
||||||
|
decision: {
|
||||||
|
stageId: activeStage.id,
|
||||||
|
stageType: activeStage.type,
|
||||||
|
outcome: "changes_requested",
|
||||||
|
body: input.commentBody.trim(),
|
||||||
|
},
|
||||||
|
workflowControlledAssignment: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
patch.status = "in_progress";
|
}
|
||||||
Object.assign(patch, patchForPrincipal(existingState.returnAssignee));
|
|
||||||
patch.executionState = buildChangesRequestedState(existingState, currentStage);
|
if (
|
||||||
|
input.issue.status !== "in_review" ||
|
||||||
|
!principalsEqual(currentAssignee, currentParticipant) ||
|
||||||
|
!principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) ||
|
||||||
|
(requestedStatus !== undefined && requestedStatus !== "in_review") ||
|
||||||
|
(requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant))
|
||||||
|
) {
|
||||||
|
buildPendingStagePatch({
|
||||||
|
patch,
|
||||||
|
previous: existingState,
|
||||||
|
policy: input.policy,
|
||||||
|
stage: activeStage,
|
||||||
|
participant: currentParticipant,
|
||||||
|
returnAssignee: existingState?.returnAssignee ?? currentAssignee ?? actor,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
patch,
|
patch,
|
||||||
decision: {
|
workflowControlledAssignment: true,
|
||||||
stageId: currentStage.id,
|
|
||||||
stageType: currentStage.type,
|
|
||||||
outcome: "changes_requested",
|
|
||||||
body: input.commentBody.trim(),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { patch };
|
return { patch };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestedStatus !== "done") {
|
const shouldStartWorkflow =
|
||||||
|
requestedStatus === "done" ||
|
||||||
|
requestedStatus === "in_review";
|
||||||
|
|
||||||
|
if (!shouldStartWorkflow) {
|
||||||
return { patch };
|
return { patch };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,14 +443,16 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||||
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
|
throw unprocessable(`No eligible ${pendingStage.type} participant is configured for this issue`);
|
||||||
}
|
}
|
||||||
|
|
||||||
patch.status = "in_review";
|
buildPendingStagePatch({
|
||||||
Object.assign(patch, patchForPrincipal(participant));
|
patch,
|
||||||
patch.executionState = buildPendingState({
|
|
||||||
previous: existingState,
|
previous: existingState,
|
||||||
|
policy: input.policy,
|
||||||
stage: pendingStage,
|
stage: pendingStage,
|
||||||
stageIndex: input.policy.stages.findIndex((stage) => stage.id === pendingStage.id),
|
|
||||||
participant,
|
participant,
|
||||||
returnAssignee,
|
returnAssignee,
|
||||||
});
|
});
|
||||||
return { patch };
|
return {
|
||||||
|
patch,
|
||||||
|
workflowControlledAssignment: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
executionWorkspaceSettings?: Record<string, unknown> | null;
|
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||||||
}) {
|
}) {
|
||||||
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
const resolvedVariables = resolveRoutineVariableValues(input.routine.variables ?? [], input);
|
||||||
|
const title = interpolateRoutineTemplate(input.routine.title, resolvedVariables) ?? input.routine.title;
|
||||||
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
|
const description = interpolateRoutineTemplate(input.routine.description, resolvedVariables);
|
||||||
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
const triggerPayload = mergeRoutineRunPayload(input.payload, resolvedVariables);
|
||||||
const run = await db.transaction(async (tx) => {
|
const run = await db.transaction(async (tx) => {
|
||||||
|
|
@ -748,7 +749,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
projectId: input.routine.projectId,
|
projectId: input.routine.projectId,
|
||||||
goalId: input.routine.goalId,
|
goalId: input.routine.goalId,
|
||||||
parentId: input.routine.parentIssueId,
|
parentId: input.routine.parentIssueId,
|
||||||
title: input.routine.title,
|
title,
|
||||||
description,
|
description,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
priority: input.routine.priority,
|
priority: input.routine.priority,
|
||||||
|
|
@ -996,7 +997,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||||
const variables = syncRoutineVariablesWithTemplate(
|
const variables = syncRoutineVariablesWithTemplate(
|
||||||
input.description,
|
[input.title, input.description],
|
||||||
sanitizeRoutineVariableInputs(input.variables),
|
sanitizeRoutineVariableInputs(input.variables),
|
||||||
);
|
);
|
||||||
assertRoutineVariableDefinitions(variables);
|
assertRoutineVariableDefinitions(variables);
|
||||||
|
|
@ -1029,9 +1030,10 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
const nextProjectId = patch.projectId ?? existing.projectId;
|
const nextProjectId = patch.projectId ?? existing.projectId;
|
||||||
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
const nextAssigneeAgentId = patch.assigneeAgentId ?? existing.assigneeAgentId;
|
||||||
|
const nextTitle = patch.title ?? existing.title;
|
||||||
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
||||||
const nextVariables = syncRoutineVariablesWithTemplate(
|
const nextVariables = syncRoutineVariablesWithTemplate(
|
||||||
nextDescription,
|
[nextTitle, nextDescription],
|
||||||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||||
);
|
);
|
||||||
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
|
if (patch.projectId) await assertProject(existing.companyId, nextProjectId);
|
||||||
|
|
@ -1060,7 +1062,7 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup
|
||||||
projectId: nextProjectId,
|
projectId: nextProjectId,
|
||||||
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
|
goalId: patch.goalId === undefined ? existing.goalId : patch.goalId,
|
||||||
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
|
parentIssueId: patch.parentIssueId === undefined ? existing.parentIssueId : patch.parentIssueId,
|
||||||
title: patch.title ?? existing.title,
|
title: nextTitle,
|
||||||
description: nextDescription,
|
description: nextDescription,
|
||||||
assigneeAgentId: nextAssigneeAgentId,
|
assigneeAgentId: nextAssigneeAgentId,
|
||||||
priority: patch.priority ?? existing.priority,
|
priority: patch.priority ?? existing.priority,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, not } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
|
import { agents, approvals, heartbeatRuns } from "@paperclipai/db";
|
||||||
import type { SidebarBadges } from "@paperclipai/shared";
|
import type { SidebarBadges } from "@paperclipai/shared";
|
||||||
|
|
@ -6,14 +6,34 @@ import type { SidebarBadges } from "@paperclipai/shared";
|
||||||
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
const ACTIONABLE_APPROVAL_STATUSES = ["pending", "revision_requested"];
|
||||||
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
const FAILED_HEARTBEAT_STATUSES = ["failed", "timed_out"];
|
||||||
|
|
||||||
|
function normalizeTimestamp(value: Date | string | null | undefined): number {
|
||||||
|
if (!value) return 0;
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDismissed(
|
||||||
|
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||||
|
itemKey: string,
|
||||||
|
activityAt: Date | string | null | undefined,
|
||||||
|
) {
|
||||||
|
const dismissedAt = dismissedAtByKey.get(itemKey);
|
||||||
|
if (dismissedAt == null) return false;
|
||||||
|
return dismissedAt >= normalizeTimestamp(activityAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function sidebarBadgeService(db: Db) {
|
export function sidebarBadgeService(db: Db) {
|
||||||
return {
|
return {
|
||||||
get: async (
|
get: async (
|
||||||
companyId: string,
|
companyId: string,
|
||||||
extra?: { joinRequests?: number; unreadTouchedIssues?: number },
|
extra?: {
|
||||||
|
dismissals?: ReadonlyMap<string, number>;
|
||||||
|
joinRequests?: Array<{ id: string; updatedAt: Date | string | null; createdAt: Date | string }>;
|
||||||
|
unreadTouchedIssues?: number;
|
||||||
|
},
|
||||||
): Promise<SidebarBadges> => {
|
): Promise<SidebarBadges> => {
|
||||||
const actionableApprovals = await db
|
const actionableApprovals = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ id: approvals.id, updatedAt: approvals.updatedAt })
|
||||||
.from(approvals)
|
.from(approvals)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
|
|
@ -21,11 +41,15 @@ export function sidebarBadgeService(db: Db) {
|
||||||
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
inArray(approvals.status, ACTIONABLE_APPROVAL_STATUSES),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
.then((rows) =>
|
||||||
|
rows.filter((row) => !isDismissed(extra?.dismissals ?? new Map(), `approval:${row.id}`, row.updatedAt)).length
|
||||||
|
);
|
||||||
|
|
||||||
const latestRunByAgent = await db
|
const latestRunByAgent = await db
|
||||||
.selectDistinctOn([heartbeatRuns.agentId], {
|
.selectDistinctOn([heartbeatRuns.agentId], {
|
||||||
|
id: heartbeatRuns.id,
|
||||||
runStatus: heartbeatRuns.status,
|
runStatus: heartbeatRuns.status,
|
||||||
|
createdAt: heartbeatRuns.createdAt,
|
||||||
})
|
})
|
||||||
.from(heartbeatRuns)
|
.from(heartbeatRuns)
|
||||||
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
.innerJoin(agents, eq(heartbeatRuns.agentId, agents.id))
|
||||||
|
|
@ -39,10 +63,17 @@ export function sidebarBadgeService(db: Db) {
|
||||||
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
|
.orderBy(heartbeatRuns.agentId, desc(heartbeatRuns.createdAt));
|
||||||
|
|
||||||
const failedRuns = latestRunByAgent.filter((row) =>
|
const failedRuns = latestRunByAgent.filter((row) =>
|
||||||
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus),
|
FAILED_HEARTBEAT_STATUSES.includes(row.runStatus)
|
||||||
|
&& !isDismissed(extra?.dismissals ?? new Map(), `run:${row.id}`, row.createdAt),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
const joinRequests = extra?.joinRequests ?? 0;
|
const joinRequests = (extra?.joinRequests ?? []).filter((row) =>
|
||||||
|
!isDismissed(
|
||||||
|
extra?.dismissals ?? new Map(),
|
||||||
|
`join:${row.id}`,
|
||||||
|
row.updatedAt ?? row.createdAt,
|
||||||
|
)
|
||||||
|
).length;
|
||||||
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
const unreadTouchedIssues = extra?.unreadTouchedIssues ?? 0;
|
||||||
return {
|
return {
|
||||||
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
inbox: actionableApprovals + failedRuns + joinRequests + unreadTouchedIssues,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
|
@ -157,6 +157,16 @@ function findWorkspaceRoot(startCwd: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLinkedGitWorktreeCheckout(rootDir: string) {
|
||||||
|
const gitMetadataPath = path.join(rootDir, ".git");
|
||||||
|
if (!existsSync(gitMetadataPath)) return false;
|
||||||
|
|
||||||
|
const stat = lstatSync(gitMetadataPath);
|
||||||
|
if (!stat.isFile()) return false;
|
||||||
|
|
||||||
|
return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:");
|
||||||
|
}
|
||||||
|
|
||||||
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
function discoverWorkspacePackagePaths(rootDir: string): Map<string, string> {
|
||||||
const packagePaths = new Map<string, string>();
|
const packagePaths = new Map<string, string>();
|
||||||
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]);
|
||||||
|
|
@ -228,6 +238,7 @@ export async function ensureServerWorkspaceLinksCurrent(
|
||||||
) {
|
) {
|
||||||
const workspaceRoot = findWorkspaceRoot(startCwd);
|
const workspaceRoot = findWorkspaceRoot(startCwd);
|
||||||
if (!workspaceRoot) return;
|
if (!workspaceRoot) return;
|
||||||
|
if (!isLinkedGitWorktreeCheckout(workspaceRoot)) return;
|
||||||
|
|
||||||
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
|
const mismatches = findServerWorkspaceLinkMismatches(workspaceRoot);
|
||||||
if (mismatches.length === 0) return;
|
if (mismatches.length === 0) return;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli
|
||||||
|
|
||||||
Follow these steps every time you wake up:
|
Follow these steps every time you wake up:
|
||||||
|
|
||||||
|
**Scoped-wake fast path.** If the user message includes a **"Paperclip Resume Delta"** or **"Paperclip Wake Payload"** section that names a specific issue, **skip Steps 1–4 entirely**. Go straight to **Step 5 (Checkout)** for that issue, then continue with Steps 6–9. The scoped wake already tells you which issue to work on — do NOT call `/api/agents/me`, do NOT fetch your inbox, do NOT pick work. Just checkout, read the wake context, do the work, and update.
|
||||||
|
|
||||||
**Step 1 — Identity.** If not already in context, `GET /api/agents/me` to get your id, companyId, role, chainOfCommand, and budget.
|
**Step 1 — Identity.** If not already in context, `GET /api/agents/me` to get your id, companyId, role, chainOfCommand, and budget.
|
||||||
|
|
||||||
**Step 2 — Approval follow-up (when triggered).** If `PAPERCLIP_APPROVAL_ID` is set (or wake reason indicates approval resolution), review the approval first:
|
**Step 2 — Approval follow-up (when triggered).** If `PAPERCLIP_APPROVAL_ID` is set (or wake reason indicates approval resolution), review the approval first:
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,8 @@ function boardRoutes() {
|
||||||
<Route path="routines" element={<Routines />} />
|
<Route path="routines" element={<Routines />} />
|
||||||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||||
<Route path="goals" element={<Goals />} />
|
<Route path="goals" element={<Goals />} />
|
||||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||||
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
<Route path="approvals" element={<Navigate to="/approvals/pending" replace />} />
|
||||||
|
|
@ -349,6 +351,8 @@ export function App() {
|
||||||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
|
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path=":companyPrefix" element={<Layout />}>
|
<Route path=":companyPrefix" element={<Layout />}>
|
||||||
|
|
|
||||||
8
ui/src/api/inboxDismissals.ts
Normal file
8
ui/src/api/inboxDismissals.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { InboxDismissal } from "@paperclipai/shared";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export const inboxDismissalsApi = {
|
||||||
|
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
|
||||||
|
dismiss: (companyId: string, itemKey: string) =>
|
||||||
|
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
|
||||||
|
};
|
||||||
|
|
@ -15,4 +15,5 @@ export { dashboardApi } from "./dashboard";
|
||||||
export { heartbeatsApi } from "./heartbeats";
|
export { heartbeatsApi } from "./heartbeats";
|
||||||
export { instanceSettingsApi } from "./instanceSettings";
|
export { instanceSettingsApi } from "./instanceSettings";
|
||||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||||
|
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||||
export { companySkillsApi } from "./companySkills";
|
export { companySkillsApi } from "./companySkills";
|
||||||
|
|
|
||||||
26
ui/src/api/issues.test.ts
Normal file
26
ui/src/api/issues.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockApi = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./client", () => ({
|
||||||
|
api: mockApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { issuesApi } from "./issues";
|
||||||
|
|
||||||
|
describe("issuesApi.list", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockApi.get.mockReset();
|
||||||
|
mockApi.get.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes parentId through to the company issues endpoint", async () => {
|
||||||
|
await issuesApi.list("company-1", { parentId: "issue-parent-1", limit: 25 });
|
||||||
|
|
||||||
|
expect(mockApi.get).toHaveBeenCalledWith(
|
||||||
|
"/companies/company-1/issues?parentId=issue-parent-1&limit=25",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -24,6 +24,7 @@ export const issuesApi = {
|
||||||
filters?: {
|
filters?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
parentId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
participantAgentId?: string;
|
participantAgentId?: string;
|
||||||
assigneeUserId?: string;
|
assigneeUserId?: string;
|
||||||
|
|
@ -42,6 +43,7 @@ export const issuesApi = {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.status) params.set("status", filters.status);
|
if (filters?.status) params.set("status", filters.status);
|
||||||
if (filters?.projectId) params.set("projectId", filters.projectId);
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
||||||
|
if (filters?.parentId) params.set("parentId", filters.parentId);
|
||||||
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId);
|
||||||
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId);
|
||||||
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId);
|
||||||
|
|
@ -80,7 +82,21 @@ export const issuesApi = {
|
||||||
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
expectedStatuses: ["todo", "backlog", "blocked", "in_review"],
|
||||||
}),
|
}),
|
||||||
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
|
release: (id: string) => api.post<Issue>(`/issues/${id}/release`, {}),
|
||||||
listComments: (id: string) => api.get<IssueComment[]>(`/issues/${id}/comments`),
|
listComments: (
|
||||||
|
id: string,
|
||||||
|
filters?: {
|
||||||
|
after?: string;
|
||||||
|
order?: "asc" | "desc";
|
||||||
|
limit?: number;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters?.after) params.set("after", filters.after);
|
||||||
|
if (filters?.order) params.set("order", filters.order);
|
||||||
|
if (filters?.limit) params.set("limit", String(filters.limit));
|
||||||
|
const qs = params.toString();
|
||||||
|
return api.get<IssueComment[]>(`/issues/${id}/comments${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
listFeedbackVotes: (id: string) => api.get<FeedbackVote[]>(`/issues/${id}/feedback-votes`),
|
||||||
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
listFeedbackTraces: (id: string, filters?: Record<string, string | boolean | undefined>) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
|
||||||
|
|
@ -2,72 +2,9 @@ import { Link } from "@/lib/router";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { formatActivityVerb } from "../lib/activity-format";
|
||||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||||
|
|
||||||
const ACTION_VERBS: Record<string, string> = {
|
|
||||||
"issue.created": "created",
|
|
||||||
"issue.updated": "updated",
|
|
||||||
"issue.checked_out": "checked out",
|
|
||||||
"issue.released": "released",
|
|
||||||
"issue.comment_added": "commented on",
|
|
||||||
"issue.attachment_added": "attached file to",
|
|
||||||
"issue.attachment_removed": "removed attachment from",
|
|
||||||
"issue.document_created": "created document for",
|
|
||||||
"issue.document_updated": "updated document on",
|
|
||||||
"issue.document_deleted": "deleted document from",
|
|
||||||
"issue.commented": "commented on",
|
|
||||||
"issue.deleted": "deleted",
|
|
||||||
"agent.created": "created",
|
|
||||||
"agent.updated": "updated",
|
|
||||||
"agent.paused": "paused",
|
|
||||||
"agent.resumed": "resumed",
|
|
||||||
"agent.terminated": "terminated",
|
|
||||||
"agent.key_created": "created API key for",
|
|
||||||
"agent.budget_updated": "updated budget for",
|
|
||||||
"agent.runtime_session_reset": "reset session for",
|
|
||||||
"heartbeat.invoked": "invoked heartbeat for",
|
|
||||||
"heartbeat.cancelled": "cancelled heartbeat for",
|
|
||||||
"approval.created": "requested approval",
|
|
||||||
"approval.approved": "approved",
|
|
||||||
"approval.rejected": "rejected",
|
|
||||||
"project.created": "created",
|
|
||||||
"project.updated": "updated",
|
|
||||||
"project.deleted": "deleted",
|
|
||||||
"goal.created": "created",
|
|
||||||
"goal.updated": "updated",
|
|
||||||
"goal.deleted": "deleted",
|
|
||||||
"cost.reported": "reported cost for",
|
|
||||||
"cost.recorded": "recorded cost for",
|
|
||||||
"company.created": "created company",
|
|
||||||
"company.updated": "updated company",
|
|
||||||
"company.archived": "archived",
|
|
||||||
"company.budget_updated": "updated budget for",
|
|
||||||
};
|
|
||||||
|
|
||||||
function humanizeValue(value: unknown): string {
|
|
||||||
if (typeof value !== "string") return String(value ?? "none");
|
|
||||||
return value.replace(/_/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatVerb(action: string, details?: Record<string, unknown> | null): string {
|
|
||||||
if (action === "issue.updated" && details) {
|
|
||||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
|
||||||
if (details.status !== undefined) {
|
|
||||||
const from = previous.status;
|
|
||||||
return from
|
|
||||||
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
|
||||||
: `changed status to ${humanizeValue(details.status)} on`;
|
|
||||||
}
|
|
||||||
if (details.priority !== undefined) {
|
|
||||||
const from = previous.priority;
|
|
||||||
return from
|
|
||||||
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
|
||||||
: `changed priority to ${humanizeValue(details.priority)} on`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ACTION_VERBS[action] ?? action.replace(/[._]/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
function entityLink(entityType: string, entityId: string, name?: string | null): string | null {
|
||||||
switch (entityType) {
|
switch (entityType) {
|
||||||
case "issue": return `/issues/${name ?? entityId}`;
|
case "issue": return `/issues/${name ?? entityId}`;
|
||||||
|
|
@ -88,7 +25,7 @@ interface ActivityRowProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
export function ActivityRow({ event, agentMap, entityNameMap, entityTitleMap, className }: ActivityRowProps) {
|
||||||
const verb = formatVerb(event.action, event.details);
|
const verb = formatActivityVerb(event.action, event.details, { agentMap });
|
||||||
|
|
||||||
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
const isHeartbeatEvent = event.entityType === "heartbeat_run";
|
||||||
const heartbeatAgentId = isHeartbeatEvent
|
const heartbeatAgentId = isHeartbeatEvent
|
||||||
|
|
|
||||||
|
|
@ -923,14 +923,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||||
<ToggleWithNumber
|
<ToggleWithNumber
|
||||||
label="Heartbeat on interval"
|
label="Heartbeat on interval"
|
||||||
hint={help.heartbeatInterval}
|
hint={help.heartbeatInterval}
|
||||||
checked={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
||||||
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
||||||
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
||||||
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
||||||
numberLabel="sec"
|
numberLabel="sec"
|
||||||
numberPrefix="Run heartbeat every"
|
numberPrefix="Run heartbeat every"
|
||||||
numberHint={help.intervalSec}
|
numberHint={help.intervalSec}
|
||||||
showNumber={eff("heartbeat", "enabled", heartbeat.enabled !== false)}
|
showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,26 @@ vi.mock("@/plugins/slots", () => ({
|
||||||
|
|
||||||
describe("CommentThread", () => {
|
describe("CommentThread", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
let writeTextMock: ReturnType<typeof vi.fn>;
|
||||||
|
let execCommandMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
|
||||||
|
writeTextMock = vi.fn(async () => {});
|
||||||
|
execCommandMock = vi.fn(() => true);
|
||||||
|
Object.assign(navigator, {
|
||||||
|
clipboard: {
|
||||||
|
writeText: writeTextMock,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, "isSecureContext", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
document.execCommand = execCommandMock;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -234,4 +248,59 @@ describe("CommentThread", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses a larger copy control with feedback and a clipboard fallback", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommentThread
|
||||||
|
comments={[{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: null,
|
||||||
|
authorUserId: "user-1",
|
||||||
|
body: "Hello from the comment body",
|
||||||
|
createdAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T11:00:00.000Z"),
|
||||||
|
}]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyButton = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(element) => element.getAttribute("aria-label") === "Copy comment as markdown",
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
|
||||||
|
expect(copyButton).toBeDefined();
|
||||||
|
expect(copyButton?.className).toContain("min-h-8");
|
||||||
|
expect(copyButton?.textContent).toContain("Copy");
|
||||||
|
|
||||||
|
Object.defineProperty(window, "isSecureContext", {
|
||||||
|
value: false,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
copyButton?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(writeTextMock).not.toHaveBeenCalled();
|
||||||
|
expect(execCommandMock).toHaveBeenCalledWith("copy");
|
||||||
|
expect(copyButton?.textContent).toContain("Copied");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(copyButton?.textContent).toContain("Copy");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -210,21 +210,71 @@ function runStatusClass(status: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTextWithFallback(text: string) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
|
||||||
|
try {
|
||||||
|
textarea.select();
|
||||||
|
const success = document.execCommand("copy");
|
||||||
|
if (!success) throw new Error("execCommand copy failed");
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function CopyMarkdownButton({ text }: { text: string }) {
|
function CopyMarkdownButton({ text }: { text: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
|
||||||
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const label = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "Copy";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className={cn(
|
||||||
title="Copy as markdown"
|
"inline-flex min-h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors",
|
||||||
|
status === "copied"
|
||||||
|
? "bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-300"
|
||||||
|
: status === "failed"
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
aria-label="Copy comment as markdown"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
void copyTextWithFallback(text)
|
||||||
setCopied(true);
|
.then(() => setStatus("copied"))
|
||||||
setTimeout(() => setCopied(false), 2000);
|
.catch(() => setStatus("failed"));
|
||||||
});
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setStatus("idle");
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, 1500);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
{status === "copied" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
<span className="sm:hidden">{label}</span>
|
||||||
|
<span className="sr-only" aria-live="polite">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { act } from "react";
|
import { act, createRef, forwardRef, useImperativeHandle } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||||
|
|
||||||
|
const { markdownEditorFocusMock } = vi.hoisted(() => ({
|
||||||
|
markdownEditorFocusMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { threadMessagesMock } = vi.hoisted(() => ({
|
||||||
|
threadMessagesMock: vi.fn(() => <div data-testid="thread-messages" />),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@assistant-ui/react", () => ({
|
vi.mock("@assistant-ui/react", () => ({
|
||||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
ThreadPrimitive: {
|
ThreadPrimitive: {
|
||||||
|
|
@ -17,7 +25,7 @@ vi.mock("@assistant-ui/react", () => ({
|
||||||
<div data-testid="thread-viewport" className={className}>{children}</div>
|
<div data-testid="thread-viewport" className={className}>{children}</div>
|
||||||
),
|
),
|
||||||
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
Messages: () => <div data-testid="thread-messages" />,
|
Messages: () => threadMessagesMock(),
|
||||||
},
|
},
|
||||||
MessagePrimitive: {
|
MessagePrimitive: {
|
||||||
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
|
@ -48,22 +56,34 @@ vi.mock("./MarkdownBody", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./MarkdownEditor", () => ({
|
vi.mock("./MarkdownEditor", () => ({
|
||||||
MarkdownEditor: ({
|
MarkdownEditor: forwardRef(({
|
||||||
value = "",
|
value = "",
|
||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}) => (
|
className?: string;
|
||||||
<textarea
|
contentClassName?: string;
|
||||||
aria-label="Issue chat editor"
|
}, ref) => {
|
||||||
placeholder={placeholder}
|
useImperativeHandle(ref, () => ({
|
||||||
value={value}
|
focus: markdownEditorFocusMock,
|
||||||
onChange={(event) => onChange?.(event.target.value)}
|
}));
|
||||||
/>
|
|
||||||
),
|
return (
|
||||||
|
<textarea
|
||||||
|
aria-label="Issue chat editor"
|
||||||
|
data-class-name={className}
|
||||||
|
data-content-class-name={contentClassName}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./InlineEntitySelector", () => ({
|
vi.mock("./InlineEntitySelector", () => ({
|
||||||
|
|
@ -100,11 +120,14 @@ describe("IssueChatThread", () => {
|
||||||
container = document.createElement("div");
|
container = document.createElement("div");
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
threadMessagesMock.mockImplementation(() => <div data-testid="thread-messages" />);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
container.remove();
|
container.remove();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
markdownEditorFocusMock.mockReset();
|
||||||
|
threadMessagesMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops the count heading and does not use an internal scrollbox", () => {
|
it("drops the count heading and does not use an internal scrollbox", () => {
|
||||||
|
|
@ -172,6 +195,48 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to a safe transcript warning when assistant-ui throws during message rendering", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
threadMessagesMock.mockImplementation(() => {
|
||||||
|
throw new Error("tapClientLookup: Index 8 out of bounds (length: 8)");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[{
|
||||||
|
id: "comment-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
issueId: "issue-1",
|
||||||
|
authorAgentId: "agent-1",
|
||||||
|
authorUserId: null,
|
||||||
|
body: "Agent summary",
|
||||||
|
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||||
|
}]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
showComposer={false}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Chat renderer hit an internal state error.");
|
||||||
|
expect(container.textContent).toContain("Agent summary");
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("stores and restores the composer draft per issue key", () => {
|
it("stores and restores the composer draft per issue key", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
@ -240,6 +305,88 @@ describe("IssueChatThread", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the composer inline with bottom breathing room and a capped editor height", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||||
|
expect(composer).not.toBeNull();
|
||||||
|
expect(composer?.className).not.toContain("sticky");
|
||||||
|
expect(composer?.className).not.toContain("bottom-0");
|
||||||
|
expect(composer?.className).toContain("pb-[calc(env(safe-area-inset-bottom)+1.5rem)]");
|
||||||
|
|
||||||
|
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
|
||||||
|
expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]");
|
||||||
|
expect(editor?.dataset.contentClassName).toContain("overflow-y-auto");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a composer focus handle that forwards to the editor", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const composerRef = createRef<{ focus: () => void }>();
|
||||||
|
const scrollByMock = vi.spyOn(window, "scrollBy").mockImplementation(() => {});
|
||||||
|
const requestAnimationFrameMock = vi
|
||||||
|
.spyOn(window, "requestAnimationFrame")
|
||||||
|
.mockImplementation((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<IssueChatThread
|
||||||
|
comments={[]}
|
||||||
|
linkedRuns={[]}
|
||||||
|
timelineEvents={[]}
|
||||||
|
liveRuns={[]}
|
||||||
|
onAdd={async () => {}}
|
||||||
|
composerRef={composerRef}
|
||||||
|
enableLiveTranscriptPolling={false}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||||
|
expect(composerRef.current).not.toBeNull();
|
||||||
|
expect(composer).not.toBeNull();
|
||||||
|
|
||||||
|
const scrollIntoViewMock = vi.fn();
|
||||||
|
composer!.scrollIntoView = scrollIntoViewMock;
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
composerRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: "smooth", block: "end" });
|
||||||
|
expect(scrollByMock).toHaveBeenCalledWith({ top: 96, behavior: "smooth" });
|
||||||
|
expect(markdownEditorFocusMock).toHaveBeenCalledTimes(1);
|
||||||
|
scrollByMock.mockRestore();
|
||||||
|
requestAnimationFrameMock.mockRestore();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
it("folds chain-of-thought when the same message transitions from running to complete", () => {
|
||||||
expect(resolveAssistantMessageFoldedState({
|
expect(resolveAssistantMessageFoldedState({
|
||||||
messageId: "message-1",
|
messageId: "message-1",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,21 @@ import {
|
||||||
useMessage,
|
useMessage,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
Component,
|
||||||
|
forwardRef,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type ErrorInfo,
|
||||||
|
type Ref,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
import { Link, useLocation } from "@/lib/router";
|
import { Link, useLocation } from "@/lib/router";
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
|
@ -65,7 +79,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
|
import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||||
|
|
||||||
interface IssueChatMessageContext {
|
interface IssueChatMessageContext {
|
||||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||||
|
|
@ -80,6 +94,7 @@ interface IssueChatMessageContext {
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
|
onImageClick?: (src: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||||
|
|
@ -144,6 +159,24 @@ interface CommentReassignment {
|
||||||
assigneeUserId: string | null;
|
assigneeUserId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IssueChatComposerHandle {
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IssueChatComposerProps {
|
||||||
|
onImageUpload?: (file: File) => Promise<string>;
|
||||||
|
onAttachImage?: (file: File) => Promise<void>;
|
||||||
|
draftKey?: string;
|
||||||
|
enableReassign?: boolean;
|
||||||
|
reassignOptions?: InlineEntityOption[];
|
||||||
|
currentAssigneeValue?: string;
|
||||||
|
suggestedAssigneeValue?: string;
|
||||||
|
mentions?: MentionOption[];
|
||||||
|
agentMap?: Map<string, Agent>;
|
||||||
|
composerDisabledReason?: string | null;
|
||||||
|
issueStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface IssueChatThreadProps {
|
interface IssueChatThreadProps {
|
||||||
comments: IssueChatComment[];
|
comments: IssueChatComment[];
|
||||||
feedbackVotes?: FeedbackVote[];
|
feedbackVotes?: FeedbackVote[];
|
||||||
|
|
@ -184,9 +217,151 @@ interface IssueChatThreadProps {
|
||||||
includeSucceededRunsWithoutOutput?: boolean;
|
includeSucceededRunsWithoutOutput?: boolean;
|
||||||
onInterruptQueued?: (runId: string) => Promise<void>;
|
onInterruptQueued?: (runId: string) => Promise<void>;
|
||||||
interruptingQueuedRunId?: string | null;
|
interruptingQueuedRunId?: string | null;
|
||||||
|
onImageClick?: (src: string) => void;
|
||||||
|
composerRef?: Ref<IssueChatComposerHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IssueChatErrorBoundaryProps = {
|
||||||
|
resetKey: string;
|
||||||
|
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
||||||
|
emptyMessage: string;
|
||||||
|
variant: "full" | "embedded";
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IssueChatErrorBoundaryState = {
|
||||||
|
hasError: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, IssueChatErrorBoundaryState> {
|
||||||
|
override state: IssueChatErrorBoundaryState = { hasError: false };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): IssueChatErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
||||||
|
console.error("Issue chat renderer failed; falling back to safe transcript view", {
|
||||||
|
error,
|
||||||
|
info: info.componentStack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidUpdate(prevProps: IssueChatErrorBoundaryProps): void {
|
||||||
|
if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) {
|
||||||
|
this.setState({ hasError: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<IssueChatFallbackThread
|
||||||
|
messages={this.props.messages}
|
||||||
|
emptyMessage={this.props.emptyMessage}
|
||||||
|
variant={this.props.variant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackAuthorLabel(message: import("@assistant-ui/react").ThreadMessage) {
|
||||||
|
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
|
||||||
|
if (typeof custom?.["authorName"] === "string") return custom["authorName"];
|
||||||
|
if (typeof custom?.["runAgentName"] === "string") return custom["runAgentName"];
|
||||||
|
if (message.role === "assistant") return "Agent";
|
||||||
|
if (message.role === "user") return "You";
|
||||||
|
return "System";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackTextParts(message: import("@assistant-ui/react").ThreadMessage) {
|
||||||
|
const contentLines: string[] = [];
|
||||||
|
for (const part of message.content) {
|
||||||
|
if (part.type === "text" || part.type === "reasoning") {
|
||||||
|
if (part.text.trim().length > 0) contentLines.push(part.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part.type === "tool-call") {
|
||||||
|
const lines = [`Tool: ${part.toolName}`];
|
||||||
|
if (part.argsText?.trim()) lines.push(`Args:\n${part.argsText}`);
|
||||||
|
if (typeof part.result === "string" && part.result.trim()) lines.push(`Result:\n${part.result}`);
|
||||||
|
contentLines.push(lines.join("\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const custom = message.metadata?.custom as Record<string, unknown> | undefined;
|
||||||
|
if (contentLines.length === 0 && typeof custom?.["waitingText"] === "string" && custom["waitingText"].trim()) {
|
||||||
|
contentLines.push(custom["waitingText"]);
|
||||||
|
}
|
||||||
|
return contentLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueChatFallbackThread({
|
||||||
|
messages,
|
||||||
|
emptyMessage,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
messages: readonly import("@assistant-ui/react").ThreadMessage[];
|
||||||
|
emptyMessage: string;
|
||||||
|
variant: "full" | "embedded";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||||
|
<div className="rounded-xl border border-amber-300/60 bg-amber-50/80 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-medium">Chat renderer hit an internal state error.</p>
|
||||||
|
<p className="text-xs opacity-80">
|
||||||
|
Showing a safe fallback transcript instead of crashing the issues page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className={cn(
|
||||||
|
"text-center text-sm text-muted-foreground",
|
||||||
|
variant === "embedded"
|
||||||
|
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||||||
|
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
||||||
|
)}>
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
|
||||||
|
{messages.map((message) => {
|
||||||
|
const lines = fallbackTextParts(message);
|
||||||
|
return (
|
||||||
|
<div key={message.id} className="rounded-xl border border-border/60 bg-card/70 px-4 py-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm">
|
||||||
|
<span className="font-medium text-foreground">{fallbackAuthorLabel(message)}</span>
|
||||||
|
{message.createdAt ? (
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{commentDateLabel(message.createdAt)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lines.length > 0 ? lines.map((line, index) => (
|
||||||
|
<MarkdownBody key={`${message.id}:fallback:${index}`}>{line}</MarkdownBody>
|
||||||
|
)) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No message content.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DRAFT_DEBOUNCE_MS = 800;
|
const DRAFT_DEBOUNCE_MS = 800;
|
||||||
|
const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96;
|
||||||
|
|
||||||
function toIsoString(value: string | Date | null | undefined): string | null {
|
function toIsoString(value: string | Date | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
@ -246,8 +421,9 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||||
|
const { onImageClick } = useContext(IssueChatCtx);
|
||||||
return (
|
return (
|
||||||
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined}>
|
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined} onImageClick={onImageClick}>
|
||||||
{text}
|
{text}
|
||||||
</MarkdownBody>
|
</MarkdownBody>
|
||||||
);
|
);
|
||||||
|
|
@ -815,25 +991,26 @@ function IssueChatAssistantMessage() {
|
||||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||||||
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||||
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||||
const isFoldable = !isRunning && hasCoT && !!chainOfThoughtLabel;
|
const isFoldable = !isRunning && !!chainOfThoughtLabel;
|
||||||
const [folded, setFolded] = useState(isFoldable);
|
const [folded, setFolded] = useState(isFoldable);
|
||||||
const previousMessageIdRef = useRef<string | null>(message.id);
|
const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable });
|
||||||
const previousIsFoldableRef = useRef(isFoldable);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Derive fold state synchronously during render (not in useEffect) so the
|
||||||
|
// browser never paints the un-folded intermediate state — prevents the
|
||||||
|
// visible "jump" when loading a page with already-folded work sections.
|
||||||
|
if (message.id !== prevFoldKey.messageId || isFoldable !== prevFoldKey.isFoldable) {
|
||||||
const nextFolded = resolveAssistantMessageFoldedState({
|
const nextFolded = resolveAssistantMessageFoldedState({
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
currentFolded: folded,
|
currentFolded: folded,
|
||||||
isFoldable,
|
isFoldable,
|
||||||
previousMessageId: previousMessageIdRef.current,
|
previousMessageId: prevFoldKey.messageId,
|
||||||
previousIsFoldable: previousIsFoldableRef.current,
|
previousIsFoldable: prevFoldKey.isFoldable,
|
||||||
});
|
});
|
||||||
previousMessageIdRef.current = message.id;
|
setPrevFoldKey({ messageId: message.id, isFoldable });
|
||||||
previousIsFoldableRef.current = isFoldable;
|
|
||||||
if (nextFolded !== folded) {
|
if (nextFolded !== folded) {
|
||||||
setFolded(nextFolded);
|
setFolded(nextFolded);
|
||||||
}
|
}
|
||||||
}, [folded, isFoldable, message.id]);
|
}
|
||||||
|
|
||||||
const handleVote = async (
|
const handleVote = async (
|
||||||
vote: FeedbackVoteValue,
|
vote: FeedbackVoteValue,
|
||||||
|
|
@ -896,8 +1073,15 @@ function IssueChatAssistantMessage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{message.content.length === 0 && waitingText ? (
|
{message.content.length === 0 && waitingText ? (
|
||||||
<div className="rounded-sm bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2.5 rounded-lg px-1 py-2">
|
||||||
{waitingText}
|
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||||
|
{agentIcon ? (
|
||||||
|
<AgentIcon icon={agentIcon} className="h-4 w-4 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="shimmer-text">{waitingText}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{notices.length > 0 ? (
|
{notices.length > 0 ? (
|
||||||
|
|
@ -1350,7 +1534,7 @@ function IssueChatSystemMessage() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatComposer({
|
const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerProps>(function IssueChatComposer({
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onAttachImage,
|
onAttachImage,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
|
@ -1362,19 +1546,7 @@ function IssueChatComposer({
|
||||||
agentMap,
|
agentMap,
|
||||||
composerDisabledReason = null,
|
composerDisabledReason = null,
|
||||||
issueStatus,
|
issueStatus,
|
||||||
}: {
|
}, forwardedRef) {
|
||||||
onImageUpload?: (file: File) => Promise<string>;
|
|
||||||
onAttachImage?: (file: File) => Promise<void>;
|
|
||||||
draftKey?: string;
|
|
||||||
enableReassign?: boolean;
|
|
||||||
reassignOptions?: InlineEntityOption[];
|
|
||||||
currentAssigneeValue?: string;
|
|
||||||
suggestedAssigneeValue?: string;
|
|
||||||
mentions?: MentionOption[];
|
|
||||||
agentMap?: Map<string, Agent>;
|
|
||||||
composerDisabledReason?: string | null;
|
|
||||||
issueStatus?: string;
|
|
||||||
}) {
|
|
||||||
const api = useAui();
|
const api = useAui();
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
const [reopen, setReopen] = useState(issueStatus === "done" || issueStatus === "cancelled");
|
||||||
|
|
@ -1384,6 +1556,7 @@ function IssueChatComposer({
|
||||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||||
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
const attachInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||||
|
const composerContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1409,6 +1582,16 @@ function IssueChatComposer({
|
||||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||||
}, [effectiveSuggestedAssigneeValue]);
|
}, [effectiveSuggestedAssigneeValue]);
|
||||||
|
|
||||||
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
|
focus: () => {
|
||||||
|
composerContainerRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.scrollBy({ top: COMPOSER_FOCUS_SCROLL_PADDING_PX, behavior: "smooth" });
|
||||||
|
editorRef.current?.focus();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
if (!trimmed || submitting) return;
|
if (!trimmed || submitting) return;
|
||||||
|
|
@ -1477,7 +1660,11 @@ function IssueChatComposer({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div
|
||||||
|
ref={composerContainerRef}
|
||||||
|
data-testid="issue-chat-composer"
|
||||||
|
className="space-y-3 pt-4 pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
|
||||||
|
>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={body}
|
value={body}
|
||||||
|
|
@ -1486,10 +1673,11 @@ function IssueChatComposer({
|
||||||
mentions={mentions}
|
mentions={mentions}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
imageUploadHandler={onImageUpload}
|
imageUploadHandler={onImageUpload}
|
||||||
contentClassName="min-h-[72px] text-sm"
|
bordered
|
||||||
|
contentClassName="min-h-[72px] max-h-[28dvh] overflow-y-auto pr-1 text-sm scrollbar-auto-hide"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-end gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
{(onImageUpload || onAttachImage) ? (
|
{(onImageUpload || onAttachImage) ? (
|
||||||
<div className="mr-auto flex items-center gap-3">
|
<div className="mr-auto flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
|
|
@ -1566,7 +1754,7 @@ function IssueChatComposer({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function IssueChatThread({
|
export function IssueChatThread({
|
||||||
comments,
|
comments,
|
||||||
|
|
@ -1604,6 +1792,8 @@ export function IssueChatThread({
|
||||||
includeSucceededRunsWithoutOutput = false,
|
includeSucceededRunsWithoutOutput = false,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId = null,
|
interruptingQueuedRunId = null,
|
||||||
|
onImageClick,
|
||||||
|
composerRef,
|
||||||
}: IssueChatThreadProps) {
|
}: IssueChatThreadProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const hasScrolledRef = useRef(false);
|
const hasScrolledRef = useRef(false);
|
||||||
|
|
@ -1731,6 +1921,7 @@ export function IssueChatThread({
|
||||||
onVote,
|
onVote,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
|
onImageClick,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
feedbackVoteByTargetId,
|
feedbackVoteByTargetId,
|
||||||
|
|
@ -1741,6 +1932,7 @@ export function IssueChatThread({
|
||||||
onVote,
|
onVote,
|
||||||
onInterruptQueued,
|
onInterruptQueued,
|
||||||
interruptingQueuedRunId,
|
interruptingQueuedRunId,
|
||||||
|
onImageClick,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1758,6 +1950,10 @@ export function IssueChatThread({
|
||||||
?? (variant === "embedded"
|
?? (variant === "embedded"
|
||||||
? "No run output yet."
|
? "No run output yet."
|
||||||
: "This issue conversation is empty. Start with a message below.");
|
: "This issue conversation is empty. Start with a message below.");
|
||||||
|
const errorBoundaryResetKey = useMemo(
|
||||||
|
() => messages.map((message) => `${message.id}:${message.role}:${message.content.length}:${message.status?.type ?? "none"}`).join("|"),
|
||||||
|
[messages],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantRuntimeProvider runtime={runtime}>
|
<AssistantRuntimeProvider runtime={runtime}>
|
||||||
|
|
@ -1775,25 +1971,33 @@ export function IssueChatThread({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<ThreadPrimitive.Root className="">
|
<IssueChatErrorBoundary
|
||||||
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
resetKey={errorBoundaryResetKey}
|
||||||
<ThreadPrimitive.Empty>
|
messages={messages}
|
||||||
<div className={cn(
|
emptyMessage={resolvedEmptyMessage}
|
||||||
"text-center text-sm text-muted-foreground",
|
variant={variant}
|
||||||
variant === "embedded"
|
>
|
||||||
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
<ThreadPrimitive.Root className="">
|
||||||
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
<ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
|
||||||
)}>
|
<ThreadPrimitive.Empty>
|
||||||
{resolvedEmptyMessage}
|
<div className={cn(
|
||||||
</div>
|
"text-center text-sm text-muted-foreground",
|
||||||
</ThreadPrimitive.Empty>
|
variant === "embedded"
|
||||||
<ThreadPrimitive.Messages components={components} />
|
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
|
||||||
<div ref={bottomAnchorRef} />
|
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
|
||||||
</ThreadPrimitive.Viewport>
|
)}>
|
||||||
</ThreadPrimitive.Root>
|
{resolvedEmptyMessage}
|
||||||
|
</div>
|
||||||
|
</ThreadPrimitive.Empty>
|
||||||
|
<ThreadPrimitive.Messages components={components} />
|
||||||
|
<div ref={bottomAnchorRef} />
|
||||||
|
</ThreadPrimitive.Viewport>
|
||||||
|
</ThreadPrimitive.Root>
|
||||||
|
</IssueChatErrorBoundary>
|
||||||
|
|
||||||
{showComposer ? (
|
{showComposer ? (
|
||||||
<IssueChatComposer
|
<IssueChatComposer
|
||||||
|
ref={composerRef}
|
||||||
onImageUpload={imageUploadHandler}
|
onImageUpload={imageUploadHandler}
|
||||||
onAttachImage={onAttachImage}
|
onAttachImage={onAttachImage}
|
||||||
draftKey={draftKey}
|
draftKey={draftKey}
|
||||||
|
|
|
||||||
343
ui/src/components/IssueColumns.tsx
Normal file
343
ui/src/components/IssueColumns.tsx
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { Issue } from "@paperclipai/shared";
|
||||||
|
import { Columns3 } from "lucide-react";
|
||||||
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
|
import type { InboxIssueColumn } from "../lib/inbox";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
|
import { Identity } from "./Identity";
|
||||||
|
import { StatusIcon } from "./StatusIcon";
|
||||||
|
|
||||||
|
export const issueTrailingColumns: InboxIssueColumn[] = ["assignee", "project", "workspace", "parent", "labels", "updated"];
|
||||||
|
|
||||||
|
const issueColumnLabels: Record<InboxIssueColumn, string> = {
|
||||||
|
status: "Status",
|
||||||
|
id: "ID",
|
||||||
|
assignee: "Assignee",
|
||||||
|
project: "Project",
|
||||||
|
workspace: "Workspace",
|
||||||
|
parent: "Parent issue",
|
||||||
|
labels: "Tags",
|
||||||
|
updated: "Last updated",
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueColumnDescriptions: Record<InboxIssueColumn, string> = {
|
||||||
|
status: "Issue state chip on the left edge.",
|
||||||
|
id: "Ticket identifier like PAP-1009.",
|
||||||
|
assignee: "Assigned agent or board user.",
|
||||||
|
project: "Linked project pill with its color.",
|
||||||
|
workspace: "Execution or project workspace used for the issue.",
|
||||||
|
parent: "Parent issue identifier and title.",
|
||||||
|
labels: "Issue labels and tags.",
|
||||||
|
updated: "Latest visible activity time.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function issueActivityText(issue: Issue): string {
|
||||||
|
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
|
||||||
|
return columns
|
||||||
|
.map((column) => {
|
||||||
|
if (column === "assignee") return "minmax(7.5rem, 9.5rem)";
|
||||||
|
if (column === "project") return "minmax(6.5rem, 8.5rem)";
|
||||||
|
if (column === "workspace") return "minmax(9rem, 12rem)";
|
||||||
|
if (column === "parent") return "minmax(5rem, 7rem)";
|
||||||
|
if (column === "labels") return "minmax(8rem, 10rem)";
|
||||||
|
return "minmax(4rem, 5.5rem)";
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IssueColumnPicker({
|
||||||
|
availableColumns,
|
||||||
|
visibleColumnSet,
|
||||||
|
onToggleColumn,
|
||||||
|
onResetColumns,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
availableColumns: InboxIssueColumn[];
|
||||||
|
visibleColumnSet: ReadonlySet<InboxIssueColumn>;
|
||||||
|
onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void;
|
||||||
|
onResetColumns: () => void;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="hidden h-8 shrink-0 px-2 text-xs sm:inline-flex"
|
||||||
|
>
|
||||||
|
<Columns3 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Columns
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[300px] rounded-xl border-border/70 p-1.5 shadow-xl shadow-black/10">
|
||||||
|
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
|
||||||
|
Desktop issue rows
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{availableColumns.map((column) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column}
|
||||||
|
checked={visibleColumnSet.has(column)}
|
||||||
|
onSelect={(event) => event.preventDefault()}
|
||||||
|
onCheckedChange={(checked) => onToggleColumn(column, checked === true)}
|
||||||
|
className="items-start rounded-lg px-3 py-2.5 pl-8"
|
||||||
|
>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{issueColumnLabels[column]}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{issueColumnDescriptions[column]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={onResetColumns}
|
||||||
|
className="rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
Reset defaults
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">status, id, updated</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxIssueMetaLeading({
|
||||||
|
issue,
|
||||||
|
isLive,
|
||||||
|
showStatus = true,
|
||||||
|
showIdentifier = true,
|
||||||
|
statusSlot,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
isLive: boolean;
|
||||||
|
showStatus?: boolean;
|
||||||
|
showIdentifier?: boolean;
|
||||||
|
statusSlot?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showStatus ? (
|
||||||
|
<span className="hidden shrink-0 sm:inline-flex">
|
||||||
|
{statusSlot ?? <StatusIcon status={issue.status} />}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{showIdentifier ? (
|
||||||
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isLive && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
|
||||||
|
"bg-blue-500/10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-2 w-2 rounded-full",
|
||||||
|
"bg-blue-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"hidden text-[11px] font-medium sm:inline",
|
||||||
|
"text-blue-600 dark:text-blue-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxIssueTrailingColumns({
|
||||||
|
issue,
|
||||||
|
columns,
|
||||||
|
projectName,
|
||||||
|
projectColor,
|
||||||
|
workspaceName,
|
||||||
|
assigneeName,
|
||||||
|
currentUserId,
|
||||||
|
parentIdentifier,
|
||||||
|
parentTitle,
|
||||||
|
assigneeContent,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
columns: InboxIssueColumn[];
|
||||||
|
projectName: string | null;
|
||||||
|
projectColor: string | null;
|
||||||
|
workspaceName: string | null;
|
||||||
|
assigneeName: string | null;
|
||||||
|
currentUserId: string | null;
|
||||||
|
parentIdentifier: string | null;
|
||||||
|
parentTitle: string | null;
|
||||||
|
assigneeContent?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
|
||||||
|
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="grid items-center gap-2"
|
||||||
|
style={{ gridTemplateColumns: issueTrailingGridTemplate(columns) }}
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
if (column === "assignee") {
|
||||||
|
if (assigneeContent) {
|
||||||
|
return <span key={column} className="min-w-0">{assigneeContent}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issue.assigneeAgentId) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||||
|
<Identity
|
||||||
|
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
|
||||||
|
size="sm"
|
||||||
|
className="min-w-0"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issue.assigneeUserId) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs font-medium text-muted-foreground">
|
||||||
|
{userLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
Unassigned
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "project") {
|
||||||
|
if (projectName) {
|
||||||
|
const accentColor = projectColor ?? "#64748b";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={column}
|
||||||
|
className="inline-flex min-w-0 items-center gap-2 text-xs font-medium"
|
||||||
|
style={{ color: pickTextColorForPillBg(accentColor, 0.12) }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{projectName}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
No project
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "labels") {
|
||||||
|
if ((issue.labels ?? []).length > 0) {
|
||||||
|
return (
|
||||||
|
<span key={column} className="flex min-w-0 items-center gap-1 overflow-hidden text-[11px]">
|
||||||
|
{(issue.labels ?? []).slice(0, 2).map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="inline-flex min-w-0 max-w-full items-center font-medium"
|
||||||
|
style={{
|
||||||
|
color: pickTextColorForPillBg(label.color, 0.12),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label.name}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{(issue.labels ?? []).length > 2 ? (
|
||||||
|
<span className="shrink-0 text-[11px] font-medium text-muted-foreground">
|
||||||
|
+{(issue.labels ?? []).length - 2}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "workspace") {
|
||||||
|
if (!workspaceName) {
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground">
|
||||||
|
{workspaceName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "parent") {
|
||||||
|
if (!issue.parentId) {
|
||||||
|
return <span key={column} className="min-w-0" aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-xs text-muted-foreground" title={parentTitle ?? undefined}>
|
||||||
|
{parentIdentifier ? (
|
||||||
|
<span className="font-mono">{parentIdentifier}</span>
|
||||||
|
) : (
|
||||||
|
<span className="italic">Sub-issue</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (column === "updated") {
|
||||||
|
return (
|
||||||
|
<span key={column} className="min-w-0 truncate text-right text-[11px] font-medium text-muted-foreground">
|
||||||
|
{activityText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { act } from "react";
|
import { act } from "react";
|
||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import type { IssueExecutionPolicy, IssueExecutionState } from "@paperclipai/shared";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue } from "@paperclipai/shared";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
@ -143,6 +144,30 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createExecutionPolicy(overrides: Partial<IssueExecutionPolicy> = {}): IssueExecutionPolicy {
|
||||||
|
return {
|
||||||
|
mode: "normal",
|
||||||
|
commentRequired: true,
|
||||||
|
stages: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecutionState(overrides: Partial<IssueExecutionState> = {}): IssueExecutionState {
|
||||||
|
return {
|
||||||
|
status: "changes_requested",
|
||||||
|
currentStageId: "stage-1",
|
||||||
|
currentStageIndex: 0,
|
||||||
|
currentStageType: "review",
|
||||||
|
currentParticipant: { type: "agent", agentId: "agent-1", userId: null },
|
||||||
|
returnAssignee: { type: "agent", agentId: "agent-2", userId: null },
|
||||||
|
completedStageIds: [],
|
||||||
|
lastDecisionId: null,
|
||||||
|
lastDecisionOutcome: "changes_requested",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
|
function renderProperties(container: HTMLDivElement, props: ComponentProps<typeof IssueProperties>) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|
@ -201,4 +226,119 @@ describe("IssueProperties", () => {
|
||||||
|
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => {
|
||||||
|
const onUpdate = vi.fn();
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
executionPolicy: createExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "review-stage",
|
||||||
|
type: "review",
|
||||||
|
approvalsNeeded: 1,
|
||||||
|
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
childIssues: [],
|
||||||
|
onUpdate,
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const runReviewButton = Array.from(container.querySelectorAll("button"))
|
||||||
|
.find((button) => button.textContent?.includes("Run review now"));
|
||||||
|
expect(runReviewButton).not.toBeUndefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
runReviewButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith({ status: "in_review" });
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a run approval action when approval is the next runnable stage", async () => {
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
executionPolicy: createExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "approval-stage",
|
||||||
|
type: "approval",
|
||||||
|
approvalsNeeded: 1,
|
||||||
|
participants: [{ id: "participant-2", type: "user", agentId: null, userId: "user-1" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
childIssues: [],
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Run approval now");
|
||||||
|
expect(container.textContent).not.toContain("Run review now");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the run review action available after changes are requested", async () => {
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
status: "in_progress",
|
||||||
|
executionPolicy: createExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "review-stage",
|
||||||
|
type: "review",
|
||||||
|
approvalsNeeded: 1,
|
||||||
|
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
executionState: createExecutionState(),
|
||||||
|
}),
|
||||||
|
childIssues: [],
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("Run review now");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the run action while an execution stage is already pending", async () => {
|
||||||
|
const root = renderProperties(container, {
|
||||||
|
issue: createIssue({
|
||||||
|
status: "in_review",
|
||||||
|
executionPolicy: createExecutionPolicy({
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: "review-stage",
|
||||||
|
type: "review",
|
||||||
|
approvalsNeeded: 1,
|
||||||
|
participants: [{ id: "participant-1", type: "agent", agentId: "agent-1", userId: null }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
executionState: createExecutionState({
|
||||||
|
status: "pending",
|
||||||
|
currentStageType: "review",
|
||||||
|
lastDecisionOutcome: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
childIssues: [],
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain("Run review now");
|
||||||
|
expect(container.textContent).not.toContain("Run approval now");
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,26 @@ export function IssueProperties({
|
||||||
const approverTrigger = approverValues.length > 0
|
const approverTrigger = approverValues.length > 0
|
||||||
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
? <span className="text-sm truncate">{approverValues.map((value) => executionParticipantLabel(value)).join(", ")}</span>
|
||||||
: <span className="text-sm text-muted-foreground">None</span>;
|
: <span className="text-sm text-muted-foreground">None</span>;
|
||||||
|
const nextRunnableExecutionStage = (() => {
|
||||||
|
if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) {
|
||||||
|
return issue.executionState.currentStageType;
|
||||||
|
}
|
||||||
|
if (issue.executionState) return null;
|
||||||
|
if (reviewerValues.length > 0) return "review";
|
||||||
|
if (approverValues.length > 0) return "approval";
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
const runExecutionButton = (stageType: "review" | "approval") => (
|
||||||
|
<PropertyRow label="">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||||
|
onClick={() => onUpdate({ status: "in_review" })}
|
||||||
|
>
|
||||||
|
{stageType === "review" ? "Run review now" : "Run approval now"}
|
||||||
|
</button>
|
||||||
|
</PropertyRow>
|
||||||
|
);
|
||||||
const currentExecutionLabel = (() => {
|
const currentExecutionLabel = (() => {
|
||||||
if (!issue.executionState?.currentStageType) return null;
|
if (!issue.executionState?.currentStageType) return null;
|
||||||
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
|
const stageLabel = issue.executionState.currentStageType === "review" ? "Review" : "Approval";
|
||||||
|
|
@ -846,15 +866,13 @@ export function IssueProperties({
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : null}
|
||||||
<span className="text-sm text-muted-foreground">None</span>
|
|
||||||
)}
|
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
|
|
||||||
<PropertyRow label="Sub-issues">
|
<PropertyRow label="Sub-issues">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{childIssues.length > 0 ? (
|
{childIssues.length > 0
|
||||||
childIssues.map((child) => (
|
? childIssues.map((child) => (
|
||||||
<Link
|
<Link
|
||||||
key={child.id}
|
key={child.id}
|
||||||
to={`/issues/${child.identifier ?? child.id}`}
|
to={`/issues/${child.identifier ?? child.id}`}
|
||||||
|
|
@ -863,9 +881,7 @@ export function IssueProperties({
|
||||||
{child.identifier ?? child.title}
|
{child.identifier ?? child.title}
|
||||||
</Link>
|
</Link>
|
||||||
))
|
))
|
||||||
) : (
|
: null}
|
||||||
<span className="text-sm text-muted-foreground">None</span>
|
|
||||||
)}
|
|
||||||
{onAddSubIssue ? (
|
{onAddSubIssue ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -896,6 +912,7 @@ export function IssueProperties({
|
||||||
() => updateExecutionPolicy([], approverValues),
|
() => updateExecutionPolicy([], approverValues),
|
||||||
)}
|
)}
|
||||||
</PropertyPicker>
|
</PropertyPicker>
|
||||||
|
{nextRunnableExecutionStage === "review" && reviewerValues.length > 0 ? runExecutionButton("review") : null}
|
||||||
|
|
||||||
<PropertyPicker
|
<PropertyPicker
|
||||||
inline={inline}
|
inline={inline}
|
||||||
|
|
@ -914,6 +931,7 @@ export function IssueProperties({
|
||||||
() => updateExecutionPolicy(reviewerValues, []),
|
() => updateExecutionPolicy(reviewerValues, []),
|
||||||
)}
|
)}
|
||||||
</PropertyPicker>
|
</PropertyPicker>
|
||||||
|
{nextRunnableExecutionStage === "approval" && approverValues.length > 0 ? runExecutionButton("approval") : null}
|
||||||
|
|
||||||
{currentExecutionLabel && (
|
{currentExecutionLabel && (
|
||||||
<PropertyRow label="Execution">
|
<PropertyRow label="Execution">
|
||||||
|
|
|
||||||
169
ui/src/components/IssueWorkspaceCard.test.tsx
Normal file
169
ui/src/components/IssueWorkspaceCard.test.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { Issue, Project } from "@paperclipai/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { IssueWorkspaceCard } from "./IssueWorkspaceCard";
|
||||||
|
|
||||||
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
|
getExperimental: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: mockInstanceSettingsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/execution-workspaces", () => ({
|
||||||
|
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
|
useCompany: () => ({
|
||||||
|
selectedCompanyId: "company-1",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/router", () => ({
|
||||||
|
Link: ({ children, to, ...props }: ComponentProps<"a"> & { to: string }) => <a href={to} {...props}>{children}</a>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
|
return {
|
||||||
|
id: "issue-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: null,
|
||||||
|
goalId: null,
|
||||||
|
parentId: null,
|
||||||
|
title: "Issue workspace",
|
||||||
|
description: null,
|
||||||
|
status: "todo",
|
||||||
|
priority: "medium",
|
||||||
|
assigneeAgentId: null,
|
||||||
|
assigneeUserId: null,
|
||||||
|
checkoutRunId: null,
|
||||||
|
executionRunId: null,
|
||||||
|
executionAgentNameKey: null,
|
||||||
|
executionLockedAt: null,
|
||||||
|
createdByAgentId: null,
|
||||||
|
createdByUserId: null,
|
||||||
|
issueNumber: 1,
|
||||||
|
identifier: "PAP-1",
|
||||||
|
requestDepth: 0,
|
||||||
|
billingCode: null,
|
||||||
|
assigneeAdapterOverrides: null,
|
||||||
|
executionWorkspaceId: null,
|
||||||
|
executionWorkspacePreference: "shared_workspace",
|
||||||
|
executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
cancelledAt: null,
|
||||||
|
hiddenAt: null,
|
||||||
|
createdAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProject(): Project {
|
||||||
|
return {
|
||||||
|
id: "project-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
urlKey: "project-1",
|
||||||
|
goalId: null,
|
||||||
|
goalIds: [],
|
||||||
|
goals: [],
|
||||||
|
name: "Project 1",
|
||||||
|
description: null,
|
||||||
|
status: "in_progress",
|
||||||
|
leadAgentId: null,
|
||||||
|
targetDate: null,
|
||||||
|
color: "#22c55e",
|
||||||
|
env: null,
|
||||||
|
pauseReason: null,
|
||||||
|
pausedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
executionWorkspacePolicy: {
|
||||||
|
enabled: true,
|
||||||
|
defaultMode: "shared_workspace",
|
||||||
|
allowIssueOverride: true,
|
||||||
|
},
|
||||||
|
codebase: {
|
||||||
|
workspaceId: null,
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: null,
|
||||||
|
defaultRef: null,
|
||||||
|
repoName: null,
|
||||||
|
localFolder: null,
|
||||||
|
managedFolder: "/tmp/project-1",
|
||||||
|
effectiveLocalFolder: "/tmp/project-1",
|
||||||
|
origin: "managed_checkout",
|
||||||
|
},
|
||||||
|
workspaces: [],
|
||||||
|
primaryWorkspace: null,
|
||||||
|
createdAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-04-08T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(container: HTMLDivElement) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<IssueWorkspaceCard issue={createIssue()} project={createProject()} onUpdate={() => {}} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IssueWorkspaceCard", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
mockExecutionWorkspacesApi.list.mockReset();
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a stable skeleton while workspace settings are still loading", async () => {
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const root = renderCard(container);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-testid="issue-workspace-card-skeleton"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -8,6 +8,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
import { cn, projectWorkspaceUrl } from "../lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
import { Check, Copy, GitBranch, FolderOpen, Pencil, X } from "lucide-react";
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
@ -156,6 +157,25 @@ function statusBadge(status: string) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IssueWorkspaceCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border p-3 space-y-3" data-testid="issue-workspace-card-skeleton">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-4 w-4 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-36" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-14" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-40" />
|
||||||
|
<Skeleton className="h-3 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Main component */
|
/* Main component */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
@ -195,14 +215,15 @@ export function IssueWorkspaceCard({
|
||||||
const companyId = issue.companyId ?? selectedCompanyId;
|
const companyId = issue.companyId ?? selectedCompanyId;
|
||||||
const [editing, setEditing] = useState(initialEditing);
|
const [editing, setEditing] = useState(initialEditing);
|
||||||
|
|
||||||
const { data: experimentalSettings } = useQuery({
|
const { data: experimentalSettings, isLoading: experimentalSettingsLoading } = useQuery({
|
||||||
queryKey: queryKeys.instance.experimentalSettings,
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectWorkspacePolicyEnabled = Boolean(project?.executionWorkspacePolicy?.enabled);
|
||||||
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true
|
||||||
&& Boolean(project?.executionWorkspacePolicy?.enabled);
|
&& projectWorkspacePolicyEnabled;
|
||||||
|
|
||||||
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
|
const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined;
|
||||||
|
|
||||||
|
|
@ -314,6 +335,10 @@ export function IssueWorkspaceCard({
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
}, [currentSelection, issue.executionWorkspaceId]);
|
}, [currentSelection, issue.executionWorkspaceId]);
|
||||||
|
|
||||||
|
if (project && projectWorkspacePolicyEnabled && experimentalSettingsLoading) {
|
||||||
|
return <IssueWorkspaceCardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!policyEnabled || !project) return null;
|
if (!policyEnabled || !project) return null;
|
||||||
|
|
||||||
const showEditingControls = livePreview || editing;
|
const showEditingControls = livePreview || editing;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,14 @@ const mockAuthApi = vi.hoisted(() => ({
|
||||||
getSession: vi.fn(),
|
getSession: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||||
|
getExperimental: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../context/CompanyContext", () => ({
|
vi.mock("../context/CompanyContext", () => ({
|
||||||
useCompany: () => companyState,
|
useCompany: () => companyState,
|
||||||
}));
|
}));
|
||||||
|
|
@ -41,8 +49,30 @@ vi.mock("../api/auth", () => ({
|
||||||
authApi: mockAuthApi,
|
authApi: mockAuthApi,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/execution-workspaces", () => ({
|
||||||
|
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: mockInstanceSettingsApi,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./IssueRow", () => ({
|
vi.mock("./IssueRow", () => ({
|
||||||
IssueRow: ({ issue }: { issue: Issue }) => <div data-testid="issue-row">{issue.title}</div>,
|
IssueRow: ({
|
||||||
|
issue,
|
||||||
|
desktopMetaLeading,
|
||||||
|
desktopTrailing,
|
||||||
|
}: {
|
||||||
|
issue: Issue;
|
||||||
|
desktopMetaLeading?: ReactNode;
|
||||||
|
desktopTrailing?: ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="issue-row">
|
||||||
|
<span>{issue.title}</span>
|
||||||
|
{desktopMetaLeading}
|
||||||
|
{desktopTrailing}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./KanbanBoard", () => ({
|
vi.mock("./KanbanBoard", () => ({
|
||||||
|
|
@ -90,6 +120,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||||
labelIds: [],
|
labelIds: [],
|
||||||
myLastTouchAt: null,
|
myLastTouchAt: null,
|
||||||
lastExternalCommentAt: null,
|
lastExternalCommentAt: null,
|
||||||
|
lastActivityAt: null,
|
||||||
isUnreadForMe: false,
|
isUnreadForMe: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|
@ -148,11 +179,18 @@ describe("IssuesList", () => {
|
||||||
mockIssuesApi.list.mockReset();
|
mockIssuesApi.list.mockReset();
|
||||||
mockIssuesApi.listLabels.mockReset();
|
mockIssuesApi.listLabels.mockReset();
|
||||||
mockAuthApi.getSession.mockReset();
|
mockAuthApi.getSession.mockReset();
|
||||||
|
mockExecutionWorkspacesApi.list.mockReset();
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockReset();
|
||||||
|
mockIssuesApi.list.mockResolvedValue([]);
|
||||||
mockIssuesApi.listLabels.mockResolvedValue([]);
|
mockIssuesApi.listLabels.mockResolvedValue([]);
|
||||||
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
mockAuthApi.getSession.mockResolvedValue({ user: null, session: null });
|
||||||
|
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||||
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||||
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
container.remove();
|
container.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,4 +222,89 @@ describe("IssuesList", () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("debounces search updates so typing does not notify the page on every keystroke", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const onSearchChange = vi.fn();
|
||||||
|
const localIssue = createIssue({ id: "issue-local", identifier: "PAP-1", title: "Local issue" });
|
||||||
|
|
||||||
|
const { root } = renderWithQueryClient(
|
||||||
|
<IssuesList
|
||||||
|
issues={[localIssue]}
|
||||||
|
agents={[]}
|
||||||
|
projects={[]}
|
||||||
|
viewStateKey="paperclip:test-issues"
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
|
onUpdateIssue={() => undefined}
|
||||||
|
/>,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null;
|
||||||
|
expect(input).not.toBeNull();
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||||
|
expect(valueSetter).toBeTypeOf("function");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
if (!input || !valueSetter) return;
|
||||||
|
valueSetter.call(input, "a");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
valueSetter.call(input, "ab");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSearchChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(149);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSearchChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSearchChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSearchChange).toHaveBeenCalledWith("ab");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the inbox issue column controls and persisted column visibility", async () => {
|
||||||
|
localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "assignee"]));
|
||||||
|
|
||||||
|
const assignedIssue = createIssue({
|
||||||
|
id: "issue-assigned",
|
||||||
|
identifier: "PAP-9",
|
||||||
|
title: "Assigned issue",
|
||||||
|
assigneeAgentId: "agent-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { root } = renderWithQueryClient(
|
||||||
|
<IssuesList
|
||||||
|
issues={[assignedIssue]}
|
||||||
|
agents={[{ id: "agent-1", name: "Agent One" }]}
|
||||||
|
projects={[]}
|
||||||
|
viewStateKey="paperclip:test-issues"
|
||||||
|
onUpdateIssue={() => undefined}
|
||||||
|
/>,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitForAssertion(() => {
|
||||||
|
expect(container.textContent).toContain("Columns");
|
||||||
|
expect(container.textContent).toContain("PAP-9");
|
||||||
|
expect(container.textContent).toContain("Agent One");
|
||||||
|
expect(container.textContent).not.toContain("Updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,31 @@
|
||||||
import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||||
import { groupBy } from "../lib/groupBy";
|
import { groupBy } from "../lib/groupBy";
|
||||||
import { formatDate, cn } from "../lib/utils";
|
import {
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
|
getAvailableInboxIssueColumns,
|
||||||
|
loadInboxIssueColumns,
|
||||||
|
normalizeInboxIssueColumns,
|
||||||
|
resolveIssueWorkspaceName,
|
||||||
|
saveInboxIssueColumns,
|
||||||
|
type InboxIssueColumn,
|
||||||
|
} from "../lib/inbox";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
InboxIssueMetaLeading,
|
||||||
|
InboxIssueTrailingColumns,
|
||||||
|
IssueColumnPicker,
|
||||||
|
issueActivityText,
|
||||||
|
issueTrailingColumns,
|
||||||
|
} from "./IssueColumns";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
@ -24,12 +40,13 @@ import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/component
|
||||||
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react";
|
||||||
import { KanbanBoard } from "./KanbanBoard";
|
import { KanbanBoard } from "./KanbanBoard";
|
||||||
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
import { buildIssueTree, countDescendants } from "../lib/issue-tree";
|
||||||
import type { Issue } from "@paperclipai/shared";
|
import type { Issue, Project } from "@paperclipai/shared";
|
||||||
|
|
||||||
/* ── Helpers ── */
|
/* ── Helpers ── */
|
||||||
|
|
||||||
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"];
|
||||||
const priorityOrder = ["critical", "high", "medium", "low"];
|
const priorityOrder = ["critical", "high", "medium", "low"];
|
||||||
|
const ISSUE_SEARCH_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
function statusLabel(status: string): string {
|
function statusLabel(status: string): string {
|
||||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
@ -45,7 +62,7 @@ export type IssueViewState = {
|
||||||
projects: string[];
|
projects: string[];
|
||||||
sortField: "status" | "priority" | "title" | "created" | "updated";
|
sortField: "status" | "priority" | "title" | "created" | "updated";
|
||||||
sortDir: "asc" | "desc";
|
sortDir: "asc" | "desc";
|
||||||
groupBy: "status" | "priority" | "assignee" | "none";
|
groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none";
|
||||||
viewMode: "list" | "board";
|
viewMode: "list" | "board";
|
||||||
collapsedGroups: string[];
|
collapsedGroups: string[];
|
||||||
collapsedParents: string[];
|
collapsedParents: string[];
|
||||||
|
|
@ -152,10 +169,7 @@ interface Agent {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectOption {
|
type ProjectOption = Pick<Project, "id" | "name"> & Partial<Pick<Project, "color" | "workspaces" | "executionWorkspacePolicy" | "primaryWorkspace">>;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IssuesListProps {
|
interface IssuesListProps {
|
||||||
issues: Issue[];
|
issues: Issue[];
|
||||||
|
|
@ -176,6 +190,50 @@ interface IssuesListProps {
|
||||||
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function IssueSearchInput({
|
||||||
|
value,
|
||||||
|
onDebouncedChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onDebouncedChange?: (search: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draftValue, setDraftValue] = useState(value);
|
||||||
|
const lastCommittedValueRef = useRef(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftValue(value);
|
||||||
|
lastCommittedValueRef.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onDebouncedChange || draftValue === lastCommittedValueRef.current) return;
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
lastCommittedValueRef.current = draftValue;
|
||||||
|
startTransition(() => {
|
||||||
|
onDebouncedChange(draftValue);
|
||||||
|
});
|
||||||
|
}, ISSUE_SEARCH_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [draftValue, onDebouncedChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-48 sm:w-64 md:w-80">
|
||||||
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={draftValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraftValue(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Search issues..."
|
||||||
|
className="pl-7 text-xs sm:text-sm"
|
||||||
|
aria-label="Search issues"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IssuesList({
|
export function IssuesList({
|
||||||
issues,
|
issues,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -198,7 +256,13 @@ export function IssuesList({
|
||||||
queryKey: queryKeys.auth.session,
|
queryKey: queryKeys.auth.session,
|
||||||
queryFn: () => authApi.getSession(),
|
queryFn: () => authApi.getSession(),
|
||||||
});
|
});
|
||||||
|
const { data: experimentalSettings } = useQuery({
|
||||||
|
queryKey: queryKeys.instance.experimentalSettings,
|
||||||
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||||
|
|
||||||
// Scope the storage key per company so folding/view state is independent across companies.
|
// Scope the storage key per company so folding/view state is independent across companies.
|
||||||
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey;
|
||||||
|
|
@ -212,6 +276,7 @@ export function IssuesList({
|
||||||
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
|
||||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||||
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
|
||||||
|
const [visibleIssueColumns, setVisibleIssueColumns] = useState<InboxIssueColumn[]>(loadInboxIssueColumns);
|
||||||
const deferredIssueSearch = useDeferredValue(issueSearch);
|
const deferredIssueSearch = useDeferredValue(issueSearch);
|
||||||
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
|
||||||
|
|
||||||
|
|
@ -259,12 +324,103 @@ export function IssuesList({
|
||||||
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
});
|
});
|
||||||
|
const { data: executionWorkspaces = [] } = useQuery({
|
||||||
|
queryKey: selectedCompanyId
|
||||||
|
? queryKeys.executionWorkspaces.list(selectedCompanyId)
|
||||||
|
: ["execution-workspaces", "__disabled__"],
|
||||||
|
queryFn: () => executionWorkspacesApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId && isolatedWorkspacesEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
const agentName = useCallback((id: string | null) => {
|
const agentName = useCallback((id: string | null) => {
|
||||||
if (!id || !agents) return null;
|
if (!id || !agents) return null;
|
||||||
return agents.find((a) => a.id === id)?.name ?? null;
|
return agents.find((a) => a.id === id)?.name ?? null;
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
|
const projectById = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string; color: string | null }>();
|
||||||
|
for (const project of projects ?? []) {
|
||||||
|
map.set(project.id, { name: project.name, color: project.color ?? null });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const projectWorkspaceById = useMemo(() => {
|
||||||
|
const map = new Map<string, { name: string }>();
|
||||||
|
for (const project of projects ?? []) {
|
||||||
|
for (const workspace of project.workspaces ?? []) {
|
||||||
|
map.set(workspace.id, { name: workspace.name || project.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const defaultProjectWorkspaceIdByProjectId = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const project of projects ?? []) {
|
||||||
|
const defaultWorkspaceId =
|
||||||
|
project.executionWorkspacePolicy?.defaultProjectWorkspaceId
|
||||||
|
?? project.primaryWorkspace?.id
|
||||||
|
?? null;
|
||||||
|
if (defaultWorkspaceId) map.set(project.id, defaultWorkspaceId);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const executionWorkspaceById = useMemo(() => {
|
||||||
|
const map = new Map<string, {
|
||||||
|
name: string;
|
||||||
|
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
|
||||||
|
projectWorkspaceId: string | null;
|
||||||
|
}>();
|
||||||
|
for (const workspace of executionWorkspaces) {
|
||||||
|
map.set(workspace.id, {
|
||||||
|
name: workspace.name,
|
||||||
|
mode: workspace.mode,
|
||||||
|
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [executionWorkspaces]);
|
||||||
|
|
||||||
|
const workspaceNameMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const [workspaceId, workspace] of projectWorkspaceById) {
|
||||||
|
map.set(workspaceId, workspace.name);
|
||||||
|
}
|
||||||
|
for (const [workspaceId, workspace] of executionWorkspaceById) {
|
||||||
|
map.set(workspaceId, workspace.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [executionWorkspaceById, projectWorkspaceById]);
|
||||||
|
|
||||||
|
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
|
||||||
|
const availableIssueColumns = useMemo(
|
||||||
|
() => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled),
|
||||||
|
[isolatedWorkspacesEnabled],
|
||||||
|
);
|
||||||
|
const availableIssueColumnSet = useMemo(() => new Set(availableIssueColumns), [availableIssueColumns]);
|
||||||
|
const visibleTrailingIssueColumns = useMemo(
|
||||||
|
() => issueTrailingColumns.filter((column) => visibleIssueColumnSet.has(column) && availableIssueColumnSet.has(column)),
|
||||||
|
[availableIssueColumnSet, visibleIssueColumnSet],
|
||||||
|
);
|
||||||
|
|
||||||
|
const issueById = useMemo(() => {
|
||||||
|
const map = new Map<string, Issue>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
map.set(issue.id, issue);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [issues]);
|
||||||
|
|
||||||
|
const issueTitleMap = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
map.set(issue.id, issue.identifier ? `${issue.identifier}: ${issue.title}` : issue.title);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [issues]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
|
||||||
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
|
||||||
|
|
@ -295,6 +451,36 @@ export function IssuesList({
|
||||||
.filter((p) => groups[p]?.length)
|
.filter((p) => groups[p]?.length)
|
||||||
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
.map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! }));
|
||||||
}
|
}
|
||||||
|
if (viewState.groupBy === "workspace") {
|
||||||
|
const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace");
|
||||||
|
return Object.keys(groups)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Groups with items first, "no workspace" last
|
||||||
|
if (a === "__no_workspace") return 1;
|
||||||
|
if (b === "__no_workspace") return -1;
|
||||||
|
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
|
||||||
|
})
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: key === "__no_workspace" ? "No Workspace" : (workspaceNameMap.get(key) ?? key.slice(0, 8)),
|
||||||
|
items: groups[key]!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (viewState.groupBy === "parent") {
|
||||||
|
const groups = groupBy(filtered, (i) => i.parentId ?? "__no_parent");
|
||||||
|
return Object.keys(groups)
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Groups with items first, "no parent" last
|
||||||
|
if (a === "__no_parent") return 1;
|
||||||
|
if (b === "__no_parent") return -1;
|
||||||
|
return (groups[b]?.length ?? 0) - (groups[a]?.length ?? 0);
|
||||||
|
})
|
||||||
|
.map((key) => ({
|
||||||
|
key,
|
||||||
|
label: key === "__no_parent" ? "No Parent" : (issueTitleMap.get(key) ?? key.slice(0, 8)),
|
||||||
|
items: groups[key]!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
// assignee
|
// assignee
|
||||||
const groups = groupBy(
|
const groups = groupBy(
|
||||||
filtered,
|
filtered,
|
||||||
|
|
@ -310,7 +496,7 @@ export function IssuesList({
|
||||||
: (agentName(key) ?? key.slice(0, 8)),
|
: (agentName(key) ?? key.slice(0, 8)),
|
||||||
items: groups[key]!,
|
items: groups[key]!,
|
||||||
}));
|
}));
|
||||||
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
|
}, [filtered, viewState.groupBy, agents, agentName, currentUserId, workspaceNameMap, issueTitleMap]);
|
||||||
|
|
||||||
const newIssueDefaults = useCallback((groupKey?: string) => {
|
const newIssueDefaults = useCallback((groupKey?: string) => {
|
||||||
const defaults: Record<string, string> = {};
|
const defaults: Record<string, string> = {};
|
||||||
|
|
@ -322,10 +508,27 @@ export function IssuesList({
|
||||||
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
|
||||||
else defaults.assigneeAgentId = groupKey;
|
else defaults.assigneeAgentId = groupKey;
|
||||||
}
|
}
|
||||||
|
else if (viewState.groupBy === "parent" && groupKey !== "__no_parent") {
|
||||||
|
defaults.parentId = groupKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return defaults;
|
return defaults;
|
||||||
}, [projectId, viewState.groupBy]);
|
}, [projectId, viewState.groupBy]);
|
||||||
|
|
||||||
|
const setIssueColumns = useCallback((next: InboxIssueColumn[]) => {
|
||||||
|
const normalized = normalizeInboxIssueColumns(next);
|
||||||
|
setVisibleIssueColumns(normalized);
|
||||||
|
saveInboxIssueColumns(normalized);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleIssueColumn = useCallback((column: InboxIssueColumn, enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
setIssueColumns([...visibleIssueColumns, column]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIssueColumns(visibleIssueColumns.filter((value) => value !== column));
|
||||||
|
}, [setIssueColumns, visibleIssueColumns]);
|
||||||
|
|
||||||
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
|
||||||
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
|
||||||
setAssigneePickerIssueId(null);
|
setAssigneePickerIssueId(null);
|
||||||
|
|
@ -342,19 +545,13 @@ export function IssuesList({
|
||||||
<Plus className="h-4 w-4 sm:mr-1" />
|
<Plus className="h-4 w-4 sm:mr-1" />
|
||||||
<span className="hidden sm:inline">New Issue</span>
|
<span className="hidden sm:inline">New Issue</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="relative w-48 sm:w-64 md:w-80">
|
<IssueSearchInput
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
value={issueSearch}
|
||||||
<Input
|
onDebouncedChange={(nextSearch) => {
|
||||||
value={issueSearch}
|
setIssueSearch(nextSearch);
|
||||||
onChange={(e) => {
|
onSearchChange?.(nextSearch);
|
||||||
setIssueSearch(e.target.value);
|
}}
|
||||||
onSearchChange?.(e.target.value);
|
/>
|
||||||
}}
|
|
||||||
placeholder="Search issues..."
|
|
||||||
className="pl-7 text-xs sm:text-sm"
|
|
||||||
aria-label="Search issues"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
|
||||||
|
|
@ -376,6 +573,14 @@ export function IssuesList({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<IssueColumnPicker
|
||||||
|
availableColumns={availableIssueColumns}
|
||||||
|
visibleColumnSet={visibleIssueColumnSet}
|
||||||
|
onToggleColumn={toggleIssueColumn}
|
||||||
|
onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)}
|
||||||
|
title="Choose which issue columns stay visible"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -605,6 +810,8 @@ export function IssuesList({
|
||||||
["status", "Status"],
|
["status", "Status"],
|
||||||
["priority", "Priority"],
|
["priority", "Priority"],
|
||||||
["assignee", "Assignee"],
|
["assignee", "Assignee"],
|
||||||
|
["workspace", "Workspace"],
|
||||||
|
["parent", "Parent Issue"],
|
||||||
["none", "None"],
|
["none", "None"],
|
||||||
] as const).map(([value, label]) => (
|
] as const).map(([value, label]) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -684,6 +891,8 @@ export function IssuesList({
|
||||||
const hasChildren = children.length > 0;
|
const hasChildren = children.length > 0;
|
||||||
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0;
|
||||||
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
const isExpanded = !viewState.collapsedParents.includes(issue.id);
|
||||||
|
const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null;
|
||||||
|
const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null;
|
||||||
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -728,154 +937,139 @@ export function IssuesList({
|
||||||
) : (
|
) : (
|
||||||
<span className="hidden w-3.5 shrink-0 sm:block" />
|
<span className="hidden w-3.5 shrink-0 sm:block" />
|
||||||
)}
|
)}
|
||||||
<span
|
<InboxIssueMetaLeading
|
||||||
className="hidden shrink-0 sm:inline-flex"
|
issue={issue}
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
isLive={liveIssueIds?.has(issue.id) === true}
|
||||||
>
|
showStatus={visibleIssueColumnSet.has("status") && availableIssueColumnSet.has("status")}
|
||||||
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
|
||||||
</span>
|
statusSlot={(
|
||||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
|
||||||
</span>
|
|
||||||
{liveIssueIds?.has(issue.id) && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
|
|
||||||
<span className="relative flex h-2 w-2">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
|
|
||||||
</span>
|
</span>
|
||||||
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
|
)}
|
||||||
Live
|
/>
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
mobileMeta={timeAgo(issue.updatedAt)}
|
mobileMeta={issueActivityText(issue).toLowerCase()}
|
||||||
desktopTrailing={(
|
desktopTrailing={(
|
||||||
<>
|
visibleTrailingIssueColumns.length > 0 ? (
|
||||||
{(issue.labels ?? []).length > 0 && (
|
<InboxIssueTrailingColumns
|
||||||
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
|
issue={issue}
|
||||||
{(issue.labels ?? []).slice(0, 3).map((label) => (
|
columns={visibleTrailingIssueColumns}
|
||||||
<span
|
projectName={issueProject?.name ?? null}
|
||||||
key={label.id}
|
projectColor={issueProject?.color ?? null}
|
||||||
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
|
workspaceName={resolveIssueWorkspaceName(issue, {
|
||||||
style={{
|
executionWorkspaceById,
|
||||||
borderColor: label.color,
|
projectWorkspaceById,
|
||||||
color: pickTextColorForPillBg(label.color, 0.12),
|
defaultProjectWorkspaceIdByProjectId,
|
||||||
backgroundColor: `${label.color}1f`,
|
})}
|
||||||
}}
|
assigneeName={agentName(issue.assigneeAgentId)}
|
||||||
>
|
currentUserId={currentUserId}
|
||||||
{label.name}
|
parentIdentifier={parentIssue?.identifier ?? null}
|
||||||
</span>
|
parentTitle={parentIssue?.title ?? null}
|
||||||
))}
|
assigneeContent={(
|
||||||
{(issue.labels ?? []).length > 3 && (
|
<Popover
|
||||||
<span className="text-[10px] text-muted-foreground">
|
open={assigneePickerIssueId === issue.id}
|
||||||
+{(issue.labels ?? []).length - 3}
|
onOpenChange={(open) => {
|
||||||
</span>
|
setAssigneePickerIssueId(open ? issue.id : null);
|
||||||
)}
|
if (!open) setAssigneeSearch("");
|
||||||
</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 transition-colors hover:bg-accent/50"
|
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
||||||
>
|
>
|
||||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
<PopoverTrigger asChild>
|
||||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
|
||||||
) : issue.assigneeUserId ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
|
||||||
<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>
|
|
||||||
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-56 p-1"
|
|
||||||
align="end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDownOutside={() => setAssigneeSearch("")}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
|
||||||
placeholder="Search assignees..."
|
|
||||||
value={assigneeSearch}
|
|
||||||
onChange={(e) => setAssigneeSearch(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
|
||||||
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
assignIssue(issue.id, null, null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No assignee
|
|
||||||
</button>
|
|
||||||
{currentUserId && (
|
|
||||||
<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 w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||||
issue.assigneeUserId === currentUserId && "bg-accent",
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
assignIssue(issue.id, null, currentUserId);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||||
<span>Me</span>
|
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
|
||||||
|
) : issue.assigneeUserId ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||||
|
<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>
|
||||||
|
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
)}
|
</PopoverTrigger>
|
||||||
{(agents ?? [])
|
<PopoverContent
|
||||||
.filter((agent) => {
|
className="w-56 p-1"
|
||||||
if (!assigneeSearch.trim()) return true;
|
align="end"
|
||||||
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
onClick={(e) => e.stopPropagation()}
|
||||||
})
|
onPointerDownOutside={() => setAssigneeSearch("")}
|
||||||
.map((agent) => (
|
>
|
||||||
|
<input
|
||||||
|
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
|
||||||
|
placeholder="Search assignees..."
|
||||||
|
value={assigneeSearch}
|
||||||
|
onChange={(e) => setAssigneeSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||||
<button
|
<button
|
||||||
key={agent.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
|
||||||
issue.assigneeAgentId === agent.id && "bg-accent",
|
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
assignIssue(issue.id, agent.id, null);
|
assignIssue(issue.id, null, null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
No assignee
|
||||||
</button>
|
</button>
|
||||||
))}
|
{currentUserId && (
|
||||||
</div>
|
<button
|
||||||
</PopoverContent>
|
className={cn(
|
||||||
</Popover>
|
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
|
||||||
</>
|
issue.assigneeUserId === currentUserId && "bg-accent",
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
assignIssue(issue.id, null, currentUserId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span>Me</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(agents ?? [])
|
||||||
|
.filter((agent) => {
|
||||||
|
if (!assigneeSearch.trim()) return true;
|
||||||
|
return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase());
|
||||||
|
})
|
||||||
|
.map((agent) => (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
className={cn(
|
||||||
|
"flex 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, null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
)}
|
)}
|
||||||
trailingMeta={formatDate(issue.createdAt)}
|
|
||||||
/>
|
/>
|
||||||
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
|
{hasChildren && isExpanded && children.map((child) => renderIssueRow(child, depth + 1))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
101
ui/src/components/KeyboardShortcutsCheatsheet.tsx
Normal file
101
ui/src/components/KeyboardShortcutsCheatsheet.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
interface ShortcutEntry {
|
||||||
|
keys: string[];
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutSection {
|
||||||
|
title: string;
|
||||||
|
shortcuts: ShortcutEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: ShortcutSection[] = [
|
||||||
|
{
|
||||||
|
title: "Inbox",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["j"], label: "Move down" },
|
||||||
|
{ keys: ["k"], label: "Move up" },
|
||||||
|
{ keys: ["Enter"], label: "Open selected item" },
|
||||||
|
{ keys: ["a"], label: "Archive item" },
|
||||||
|
{ keys: ["y"], label: "Archive item" },
|
||||||
|
{ keys: ["r"], label: "Mark as read" },
|
||||||
|
{ keys: ["U"], label: "Mark as unread" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Issue detail",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["y"], label: "Quick-archive back to inbox" },
|
||||||
|
{ keys: ["g", "i"], label: "Go to inbox" },
|
||||||
|
{ keys: ["g", "c"], label: "Focus comment composer" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Global",
|
||||||
|
shortcuts: [
|
||||||
|
{ keys: ["c"], label: "New issue" },
|
||||||
|
{ keys: ["["], label: "Toggle sidebar" },
|
||||||
|
{ keys: ["]"], label: "Toggle panel" },
|
||||||
|
{ keys: ["?"], label: "Show keyboard shortcuts" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function KeyCap({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<kbd className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs font-medium text-foreground shadow-[0_1px_0_1px_hsl(var(--border))]">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcutsCheatsheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden" showCloseButton={false}>
|
||||||
|
<DialogHeader className="px-5 pt-5 pb-3">
|
||||||
|
<DialogTitle className="text-base">Keyboard shortcuts</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="divide-y divide-border border-t border-border">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<div key={section.title} className="px-5 py-3">
|
||||||
|
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{section.shortcuts.map((shortcut) => (
|
||||||
|
<div
|
||||||
|
key={shortcut.label + shortcut.keys.join()}
|
||||||
|
className="flex items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground/90">{shortcut.label}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{shortcut.keys.map((key, i) => (
|
||||||
|
<span key={key} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-xs text-muted-foreground">then</span>}
|
||||||
|
<KeyCap>{key}</KeyCap>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border px-5 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Press <KeyCap>Esc</KeyCap> to close · Shortcuts are disabled in text fields
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import { NewIssueDialog } from "./NewIssueDialog";
|
||||||
import { NewProjectDialog } from "./NewProjectDialog";
|
import { NewProjectDialog } from "./NewProjectDialog";
|
||||||
import { NewGoalDialog } from "./NewGoalDialog";
|
import { NewGoalDialog } from "./NewGoalDialog";
|
||||||
import { NewAgentDialog } from "./NewAgentDialog";
|
import { NewAgentDialog } from "./NewAgentDialog";
|
||||||
|
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
||||||
import { ToastViewport } from "./ToastViewport";
|
import { ToastViewport } from "./ToastViewport";
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
|
|
@ -32,6 +33,7 @@ import {
|
||||||
normalizeRememberedInstanceSettingsPath,
|
normalizeRememberedInstanceSettingsPath,
|
||||||
} from "../lib/instance-settings";
|
} from "../lib/instance-settings";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { NotFoundPage } from "../pages/NotFound";
|
import { NotFoundPage } from "../pages/NotFound";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -69,6 +71,7 @@ export function Layout() {
|
||||||
const lastMainScrollTop = useRef(0);
|
const lastMainScrollTop = useRef(0);
|
||||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||||
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
const [instanceSettingsTarget, setInstanceSettingsTarget] = useState<string>(() => readRememberedInstanceSettingsPath());
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||||
const matchedCompany = useMemo(() => {
|
const matchedCompany = useMemo(() => {
|
||||||
if (!companyPrefix) return null;
|
if (!companyPrefix) return null;
|
||||||
|
|
@ -151,6 +154,7 @@ export function Layout() {
|
||||||
onNewIssue: () => openNewIssue(),
|
onNewIssue: () => openNewIssue(),
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
onTogglePanel: togglePanel,
|
onTogglePanel: togglePanel,
|
||||||
|
onShowShortcuts: () => setShortcutsOpen(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -265,6 +269,12 @@ export function Layout() {
|
||||||
}
|
}
|
||||||
}, [location.hash, location.pathname, location.search]);
|
}, [location.hash, location.pathname, location.search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const mainContent = document.getElementById("main-content");
|
||||||
|
return scheduleMainContentFocus(mainContent);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -420,7 +430,7 @@ export function Layout() {
|
||||||
id="main-content"
|
id="main-content"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 p-4 md:p-6",
|
"flex-1 p-4 outline-none md:p-6",
|
||||||
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -443,6 +453,7 @@ export function Layout() {
|
||||||
<NewProjectDialog />
|
<NewProjectDialog />
|
||||||
<NewGoalDialog />
|
<NewGoalDialog />
|
||||||
<NewAgentDialog />
|
<NewAgentDialog />
|
||||||
|
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
</div>
|
</div>
|
||||||
</GeneralSettingsProvider>
|
</GeneralSettingsProvider>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ interface MarkdownBodyProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||||
resolveImageSrc?: (src: string) => string | null;
|
resolveImageSrc?: (src: string) => string | null;
|
||||||
|
/** Called when a user clicks an inline image */
|
||||||
|
onImageClick?: (src: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
||||||
|
|
@ -92,7 +94,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) {
|
export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const components: Components = {
|
const components: Components = {
|
||||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||||
|
|
@ -132,10 +134,19 @@ export function MarkdownBody({ children, className, style, resolveImageSrc }: Ma
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (resolveImageSrc) {
|
if (resolveImageSrc || onImageClick) {
|
||||||
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
components.img = ({ node: _node, src, alt, ...imgProps }) => {
|
||||||
const resolved = src ? resolveImageSrc(src) : null;
|
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
||||||
return <img {...imgProps} src={resolved ?? src} alt={alt ?? ""} />;
|
const finalSrc = resolved ?? src;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
{...imgProps}
|
||||||
|
src={finalSrc}
|
||||||
|
alt={alt ?? ""}
|
||||||
|
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
||||||
|
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
return map;
|
return map;
|
||||||
}, [mentions]);
|
}, [mentions]);
|
||||||
|
|
||||||
|
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||||
|
ref.current = instance;
|
||||||
|
if (!instance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (valueRef.current !== latestValueRef.current) {
|
||||||
|
// Re-apply the latest controlled value once MDXEditor exposes its imperative API.
|
||||||
|
echoIgnoreMarkdownRef.current = valueRef.current;
|
||||||
|
instance.setMarkdown(valueRef.current);
|
||||||
|
latestValueRef.current = valueRef.current;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
|
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
|
||||||
if (!mentionState) return [];
|
if (!mentionState) return [];
|
||||||
const q = mentionState.query.trim().toLowerCase();
|
const q = mentionState.query.trim().toLowerCase();
|
||||||
|
|
@ -379,16 +392,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||||
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
|
||||||
}, [mentionState, mentions, slashCommands]);
|
}, [mentionState, mentions, slashCommands]);
|
||||||
|
|
||||||
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
|
||||||
ref.current = instance;
|
|
||||||
if (instance) {
|
|
||||||
const v = valueRef.current;
|
|
||||||
echoIgnoreMarkdownRef.current = v;
|
|
||||||
instance.setMarkdown(v);
|
|
||||||
latestValueRef.current = v;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
buildOnboardingProjectPayload,
|
buildOnboardingProjectPayload,
|
||||||
selectDefaultCompanyGoalId
|
selectDefaultCompanyGoalId
|
||||||
} from "../lib/onboarding-launch";
|
} from "../lib/onboarding-launch";
|
||||||
|
import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||||
DEFAULT_CODEX_LOCAL_MODEL
|
DEFAULT_CODEX_LOCAL_MODEL
|
||||||
|
|
@ -460,15 +461,7 @@ export function OnboardingWizard() {
|
||||||
role: "ceo",
|
role: "ceo",
|
||||||
adapterType,
|
adapterType,
|
||||||
adapterConfig: buildAdapterConfig(),
|
adapterConfig: buildAdapterConfig(),
|
||||||
runtimeConfig: {
|
runtimeConfig: buildNewAgentRuntimeConfig()
|
||||||
heartbeat: {
|
|
||||||
enabled: true,
|
|
||||||
intervalSec: 3600,
|
|
||||||
wakeOnDemand: true,
|
|
||||||
cooldownSec: 10,
|
|
||||||
maxConcurrentRuns: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
setCreatedAgentId(agent.id);
|
setCreatedAgentId(agent.id);
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
|
|
|
||||||
|
|
@ -36,18 +36,20 @@ function updateVariableList(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoutineVariablesEditor({
|
export function RoutineVariablesEditor({
|
||||||
|
title,
|
||||||
description,
|
description,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
value: RoutineVariable[];
|
value: RoutineVariable[];
|
||||||
onChange: (value: RoutineVariable[]) => void;
|
onChange: (value: RoutineVariable[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const syncedVariables = useMemo(
|
const syncedVariables = useMemo(
|
||||||
() => syncRoutineVariablesWithTemplate(description, value),
|
() => syncRoutineVariablesWithTemplate([title, description], value),
|
||||||
[description, value],
|
[description, title, value],
|
||||||
);
|
);
|
||||||
const syncedSignature = serializeVariables(syncedVariables);
|
const syncedSignature = serializeVariables(syncedVariables);
|
||||||
const currentSignature = serializeVariables(value);
|
const currentSignature = serializeVariables(value);
|
||||||
|
|
@ -68,7 +70,7 @@ export function RoutineVariablesEditor({
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Variables</p>
|
<p className="text-sm font-medium">Variables</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Detected from `{"{{name}}"}` placeholders in the routine instructions.
|
Detected from `{"{{name}}"}` placeholders in the routine title and instructions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,18 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
import { getWorktreeUiBranding } from "../lib/worktree-branding";
|
||||||
|
|
||||||
export function WorktreeBanner() {
|
export function WorktreeBanner() {
|
||||||
const branding = getWorktreeUiBranding();
|
const branding = getWorktreeUiBranding();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopyName = useCallback(() => {
|
||||||
|
if (!branding) return;
|
||||||
|
navigator.clipboard.writeText(branding.name).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
}, [branding]);
|
||||||
|
|
||||||
if (!branding) return null;
|
if (!branding) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -18,7 +29,14 @@ export function WorktreeBanner() {
|
||||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||||
<span className="shrink-0 opacity-70">Worktree</span>
|
<span className="shrink-0 opacity-70">Worktree</span>
|
||||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-70" aria-hidden="true" />
|
||||||
<span className="truncate font-semibold tracking-[0.12em]">{branding.name}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyName}
|
||||||
|
title="Click to copy worktree name"
|
||||||
|
className="truncate font-semibold tracking-[0.12em] cursor-pointer hover:opacity-80 transition-opacity bg-transparent border-none p-0 text-current uppercase text-[11px]"
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : branding.name}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
118
ui/src/components/transcript/useLiveRunTranscripts.test.tsx
Normal file
118
ui/src/components/transcript/useLiveRunTranscripts.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useLiveRunTranscripts } from "./useLiveRunTranscripts";
|
||||||
|
|
||||||
|
const { useQueryMock, logMock } = vi.hoisted(() => ({
|
||||||
|
useQueryMock: vi.fn(() => ({ data: { censorUsernameInLogs: false } })),
|
||||||
|
logMock: vi.fn(async () => ({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 0 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tanstack/react-query", () => ({
|
||||||
|
useQuery: useQueryMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api/instanceSettings", () => ({
|
||||||
|
instanceSettingsApi: {
|
||||||
|
getGeneral: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api/heartbeats", () => ({
|
||||||
|
heartbeatsApi: {
|
||||||
|
log: logMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../adapters", () => ({
|
||||||
|
buildTranscript: (chunks: unknown[]) => chunks,
|
||||||
|
getUIAdapter: () => null,
|
||||||
|
onAdapterChange: () => () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static readonly CONNECTING = 0;
|
||||||
|
static readonly OPEN = 1;
|
||||||
|
static readonly CLOSING = 2;
|
||||||
|
static readonly CLOSED = 3;
|
||||||
|
static instances: FakeWebSocket[] = [];
|
||||||
|
|
||||||
|
readonly url: string;
|
||||||
|
readyState = FakeWebSocket.CONNECTING;
|
||||||
|
onopen: ((event: Event) => void) | null = null;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
onerror: ((event: Event) => void) | null = null;
|
||||||
|
onclose: ((event: CloseEvent) => void) | null = null;
|
||||||
|
closeCalls: Array<{ code?: number; reason?: string }> = [];
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(code?: number, reason?: string) {
|
||||||
|
this.closeCalls.push({ code, reason });
|
||||||
|
this.readyState = FakeWebSocket.CLOSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerOpen() {
|
||||||
|
this.readyState = FakeWebSocket.OPEN;
|
||||||
|
this.onopen?.(new Event("open"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("useLiveRunTranscripts", () => {
|
||||||
|
const OriginalWebSocket = globalThis.WebSocket;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
useQueryMock.mockClear();
|
||||||
|
logMock.mockClear();
|
||||||
|
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.WebSocket = OriginalWebSocket;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a connecting socket to open before closing it during cleanup", async () => {
|
||||||
|
function Harness() {
|
||||||
|
useLiveRunTranscripts({
|
||||||
|
companyId: "company-1",
|
||||||
|
runs: [{ id: "run-1", status: "running", adapterType: "codex_local" }],
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||||
|
const socket = FakeWebSocket.instances[0];
|
||||||
|
expect(socket.closeCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(socket.closeCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.triggerOpen();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(socket.closeCalls).toEqual([{ code: 1000, reason: "live_run_transcripts_unmount" }]);
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -281,7 +281,16 @@ export function useLiveRunTranscripts({
|
||||||
socket.onmessage = null;
|
socket.onmessage = null;
|
||||||
socket.onerror = null;
|
socket.onerror = null;
|
||||||
socket.onclose = null;
|
socket.onclose = null;
|
||||||
socket.close(1000, "live_run_transcripts_unmount");
|
if (socket.readyState === WebSocket.CONNECTING) {
|
||||||
|
// Defer the close until the handshake completes so the browser
|
||||||
|
// does not emit a noisy "closed before the connection is established"
|
||||||
|
// warning during rapid run teardown.
|
||||||
|
socket.onopen = () => {
|
||||||
|
socket?.close(1000, "live_run_transcripts_unmount");
|
||||||
|
};
|
||||||
|
} else if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close(1000, "live_run_transcripts_unmount");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [activeRunIds, companyId, runById]);
|
}, [activeRunIds, companyId, runById]);
|
||||||
|
|
|
||||||
|
|
@ -38,20 +38,24 @@ const buttonVariants = cva(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
const Button = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
>(function Button({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}, ref) {
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
ref={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
|
|
@ -59,6 +63,8 @@ function Button({
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
|
||||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
it("refreshes touched inbox queries for issue activity", () => {
|
it("refreshes touched inbox queries and only the changed issue data for issue updates", () => {
|
||||||
const invalidations: unknown[] = [];
|
const invalidations: unknown[] = [];
|
||||||
const queryClient = {
|
const queryClient = {
|
||||||
invalidateQueries: (input: unknown) => {
|
invalidateQueries: (input: unknown) => {
|
||||||
|
|
@ -20,6 +20,7 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
{
|
{
|
||||||
entityType: "issue",
|
entityType: "issue",
|
||||||
entityId: "issue-1",
|
entityId: "issue-1",
|
||||||
|
action: "issue.updated",
|
||||||
details: null,
|
details: null,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -33,6 +34,58 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||||
expect(invalidations).toContainEqual({
|
expect(invalidations).toContainEqual({
|
||||||
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
||||||
});
|
});
|
||||||
|
expect(invalidations).toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.detail("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.activity("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.comments("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.runs("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.documents("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.attachments("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.approvals("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.liveRuns("issue-1"),
|
||||||
|
});
|
||||||
|
expect(invalidations).not.toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.activeRun("issue-1"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refreshes comments when a comment activity event arrives", () => {
|
||||||
|
const invalidations: unknown[] = [];
|
||||||
|
const queryClient = {
|
||||||
|
invalidateQueries: (input: unknown) => {
|
||||||
|
invalidations.push(input);
|
||||||
|
},
|
||||||
|
getQueryData: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||||
|
queryClient as never,
|
||||||
|
"company-1",
|
||||||
|
{
|
||||||
|
entityType: "issue",
|
||||||
|
entityId: "issue-1",
|
||||||
|
action: "issue.comment_added",
|
||||||
|
details: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(invalidations).toContainEqual({
|
||||||
|
queryKey: queryKeys.issues.comments("issue-1"),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,7 @@ function invalidateActivityQueries(
|
||||||
|
|
||||||
const entityType = readString(payload.entityType);
|
const entityType = readString(payload.entityType);
|
||||||
const entityId = readString(payload.entityId);
|
const entityId = readString(payload.entityId);
|
||||||
|
const action = readString(payload.action);
|
||||||
|
|
||||||
if (entityType === "issue") {
|
if (entityType === "issue") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||||
|
|
@ -498,14 +499,10 @@ function invalidateActivityQueries(
|
||||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||||
for (const ref of issueRefs) {
|
for (const ref of issueRefs) {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
if (action === "issue.comment_added") {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { inboxDismissalsApi } from "../api/inboxDismissals";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { dashboardApi } from "../api/dashboard";
|
import { dashboardApi } from "../api/dashboard";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
|
buildInboxDismissedAtByKey,
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
loadDismissedInboxItems,
|
loadDismissedInboxAlerts,
|
||||||
saveDismissedInboxItems,
|
saveDismissedInboxAlerts,
|
||||||
loadReadInboxItems,
|
loadReadInboxItems,
|
||||||
saveReadInboxItems,
|
saveReadInboxItems,
|
||||||
READ_ITEMS_KEY,
|
READ_ITEMS_KEY,
|
||||||
|
|
@ -19,13 +21,13 @@ import {
|
||||||
|
|
||||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
||||||
|
|
||||||
export function useDismissedInboxItems() {
|
export function useDismissedInboxAlerts() {
|
||||||
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
|
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxAlerts);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStorage = (event: StorageEvent) => {
|
const handleStorage = (event: StorageEvent) => {
|
||||||
if (event.key !== "paperclip:inbox:dismissed") return;
|
if (event.key !== "paperclip:inbox:dismissed") return;
|
||||||
setDismissed(loadDismissedInboxItems());
|
setDismissed(loadDismissedInboxAlerts());
|
||||||
};
|
};
|
||||||
window.addEventListener("storage", handleStorage);
|
window.addEventListener("storage", handleStorage);
|
||||||
return () => window.removeEventListener("storage", handleStorage);
|
return () => window.removeEventListener("storage", handleStorage);
|
||||||
|
|
@ -35,7 +37,7 @@ export function useDismissedInboxItems() {
|
||||||
setDismissed((prev) => {
|
setDismissed((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
next.add(id);
|
next.add(id);
|
||||||
saveDismissedInboxItems(next);
|
saveDismissedInboxAlerts(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -43,6 +45,63 @@ export function useDismissedInboxItems() {
|
||||||
return { dismissed, dismiss };
|
return { dismissed, dismiss };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useInboxDismissals(companyId: string | null | undefined) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = companyId
|
||||||
|
? queryKeys.inboxDismissals(companyId)
|
||||||
|
: ["inbox-dismissals", "__disabled__"] as const;
|
||||||
|
|
||||||
|
const { data: dismissals = [] } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => inboxDismissalsApi.list(companyId!),
|
||||||
|
enabled: !!companyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissMutation = useMutation({
|
||||||
|
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
|
||||||
|
onMutate: async ({ itemKey }) => {
|
||||||
|
if (!companyId) return { previous: [] as typeof dismissals };
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
|
||||||
|
const now = new Date();
|
||||||
|
queryClient.setQueryData(queryKey, [
|
||||||
|
{
|
||||||
|
id: `optimistic:${itemKey}`,
|
||||||
|
companyId,
|
||||||
|
userId: "me",
|
||||||
|
itemKey,
|
||||||
|
dismissedAt: now,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
...previous.filter((dismissal) => dismissal.itemKey !== itemKey),
|
||||||
|
]);
|
||||||
|
return { previous };
|
||||||
|
},
|
||||||
|
onError: (_error, _variables, context) => {
|
||||||
|
if (!context) return;
|
||||||
|
queryClient.setQueryData(queryKey, context.previous);
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
if (!companyId) return;
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissedAtByKey = useMemo(
|
||||||
|
() => buildInboxDismissedAtByKey(dismissals),
|
||||||
|
[dismissals],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dismissals,
|
||||||
|
dismissedAtByKey,
|
||||||
|
dismiss: (itemKey: string) => dismissMutation.mutate({ itemKey }),
|
||||||
|
isPending: dismissMutation.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useReadInboxItems() {
|
export function useReadInboxItems() {
|
||||||
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
const [readItems, setReadItems] = useState<Set<string>>(loadReadInboxItems);
|
||||||
|
|
||||||
|
|
@ -77,7 +136,8 @@ export function useReadInboxItems() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useInboxBadge(companyId: string | null | undefined) {
|
export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
const { dismissed } = useDismissedInboxItems();
|
const { dismissed: dismissedAlerts } = useDismissedInboxAlerts();
|
||||||
|
const { dismissedAtByKey } = useInboxDismissals(companyId);
|
||||||
|
|
||||||
const { data: approvals = [] } = useQuery({
|
const { data: approvals = [] } = useQuery({
|
||||||
queryKey: queryKeys.approvals.list(companyId!),
|
queryKey: queryKeys.approvals.list(companyId!),
|
||||||
|
|
@ -134,8 +194,9 @@ export function useInboxBadge(companyId: string | null | undefined) {
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
mineIssues,
|
mineIssues,
|
||||||
dismissed,
|
dismissedAlerts,
|
||||||
|
dismissedAtByKey,
|
||||||
}),
|
}),
|
||||||
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed],
|
[approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissedAlerts, dismissedAtByKey],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
ui/src/hooks/useKeyboardShortcuts.test.tsx
Normal file
58
ui/src/hooks/useKeyboardShortcuts.test.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useKeyboardShortcuts } from "./useKeyboardShortcuts";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function TestHarness({
|
||||||
|
onNewIssue,
|
||||||
|
}: {
|
||||||
|
onNewIssue: () => void;
|
||||||
|
}) {
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
enabled: true,
|
||||||
|
onNewIssue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <div>keyboard shortcuts test</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useKeyboardShortcuts", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores events already claimed by another handler", () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
const onNewIssue = vi.fn();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<TestHarness onNewIssue={onNewIssue} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new KeyboardEvent("keydown", {
|
||||||
|
key: "c",
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onNewIssue).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ interface ShortcutHandlers {
|
||||||
onNewIssue?: () => void;
|
onNewIssue?: () => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
onTogglePanel?: () => void;
|
onTogglePanel?: () => void;
|
||||||
|
onShowShortcuts?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useKeyboardShortcuts({
|
export function useKeyboardShortcuts({
|
||||||
|
|
@ -13,16 +14,28 @@ export function useKeyboardShortcuts({
|
||||||
onNewIssue,
|
onNewIssue,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onTogglePanel,
|
onTogglePanel,
|
||||||
|
onShowShortcuts,
|
||||||
}: ShortcutHandlers) {
|
}: ShortcutHandlers) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't fire shortcuts when typing in inputs
|
// Don't fire shortcuts when typing in inputs
|
||||||
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
if (isKeyboardShortcutTextInputTarget(e.target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ? → Show keyboard shortcuts cheatsheet
|
||||||
|
if (e.key === "?" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onShowShortcuts?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// C → New Issue
|
// C → New Issue
|
||||||
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -44,5 +57,5 @@ export function useKeyboardShortcuts({
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel]);
|
}, [enabled, onNewIssue, onToggleSidebar, onTogglePanel, onShowShortcuts]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -294,26 +294,29 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shimmer text effect for active "Working" state */
|
/* Shimmer text effect for active "Working" state — Cursor-style sweep */
|
||||||
@keyframes shimmer-text-slide {
|
@keyframes shimmer-text-slide {
|
||||||
0% { background-position: 200% center; }
|
0% { background-position: 100% center; }
|
||||||
100% { background-position: -200% center; }
|
60% { background-position: 0% center; }
|
||||||
|
100% { background-position: 0% center; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.shimmer-text {
|
.shimmer-text {
|
||||||
--shimmer-base: hsl(var(--foreground) / 0.75);
|
--shimmer-base: var(--foreground);
|
||||||
--shimmer-highlight: hsl(var(--foreground) / 0.3);
|
--shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
110deg,
|
90deg,
|
||||||
var(--shimmer-base) 35%,
|
var(--shimmer-base) 0%,
|
||||||
|
var(--shimmer-base) 40%,
|
||||||
var(--shimmer-highlight) 50%,
|
var(--shimmer-highlight) 50%,
|
||||||
var(--shimmer-base) 65%
|
var(--shimmer-base) 60%,
|
||||||
|
var(--shimmer-base) 100%
|
||||||
);
|
);
|
||||||
background-size: 250% 100%;
|
background-size: 200% 100%;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
animation: shimmer-text-slide 2.5s ease-in-out infinite;
|
animation: shimmer-text-slide 2.5s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|
|
||||||
60
ui/src/lib/activity-format.test.ts
Normal file
60
ui/src/lib/activity-format.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatActivityVerb, formatIssueActivityAction } from "./activity-format";
|
||||||
|
|
||||||
|
describe("activity formatting", () => {
|
||||||
|
const agentMap = new Map<string, Agent>([
|
||||||
|
["agent-reviewer", { id: "agent-reviewer", name: "Reviewer Bot" } as Agent],
|
||||||
|
["agent-approver", { id: "agent-approver", name: "Approver Bot" } as Agent],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("formats blocker activity using linked issue identifiers", () => {
|
||||||
|
const details = {
|
||||||
|
addedBlockedByIssues: [
|
||||||
|
{ id: "issue-2", identifier: "PAP-22", title: "Blocked task" },
|
||||||
|
],
|
||||||
|
removedBlockedByIssues: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.blockers_updated", details)).toBe("added blocker PAP-22 to");
|
||||||
|
expect(formatIssueActivityAction("issue.blockers_updated", details)).toBe("added blocker PAP-22");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats reviewer activity using agent names", () => {
|
||||||
|
const details = {
|
||||||
|
addedParticipants: [
|
||||||
|
{ type: "agent", agentId: "agent-reviewer", userId: null },
|
||||||
|
],
|
||||||
|
removedParticipants: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot to");
|
||||||
|
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("added reviewer Reviewer Bot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats approver removals using user-aware labels", () => {
|
||||||
|
const details = {
|
||||||
|
addedParticipants: [],
|
||||||
|
removedParticipants: [
|
||||||
|
{ type: "user", agentId: null, userId: "local-board" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.approvers_updated", details)).toBe("removed approver Board from");
|
||||||
|
expect(formatIssueActivityAction("issue.approvers_updated", details)).toBe("removed approver Board");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to updated wording when reviewers are both added and removed", () => {
|
||||||
|
const details = {
|
||||||
|
addedParticipants: [
|
||||||
|
{ type: "agent", agentId: "agent-reviewer", userId: null },
|
||||||
|
],
|
||||||
|
removedParticipants: [
|
||||||
|
{ type: "agent", agentId: "agent-approver", userId: null },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on");
|
||||||
|
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers");
|
||||||
|
});
|
||||||
|
});
|
||||||
289
ui/src/lib/activity-format.ts
Normal file
289
ui/src/lib/activity-format.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
type ActivityDetails = Record<string, unknown> | null | undefined;
|
||||||
|
|
||||||
|
type ActivityParticipant = {
|
||||||
|
type: "agent" | "user";
|
||||||
|
agentId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivityIssueReference = {
|
||||||
|
id?: string | null;
|
||||||
|
identifier?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ActivityFormatOptions {
|
||||||
|
agentMap?: Map<string, Agent>;
|
||||||
|
currentUserId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||||
|
"issue.created": "created",
|
||||||
|
"issue.updated": "updated",
|
||||||
|
"issue.checked_out": "checked out",
|
||||||
|
"issue.released": "released",
|
||||||
|
"issue.comment_added": "commented on",
|
||||||
|
"issue.attachment_added": "attached file to",
|
||||||
|
"issue.attachment_removed": "removed attachment from",
|
||||||
|
"issue.document_created": "created document for",
|
||||||
|
"issue.document_updated": "updated document on",
|
||||||
|
"issue.document_deleted": "deleted document from",
|
||||||
|
"issue.commented": "commented on",
|
||||||
|
"issue.deleted": "deleted",
|
||||||
|
"agent.created": "created",
|
||||||
|
"agent.updated": "updated",
|
||||||
|
"agent.paused": "paused",
|
||||||
|
"agent.resumed": "resumed",
|
||||||
|
"agent.terminated": "terminated",
|
||||||
|
"agent.key_created": "created API key for",
|
||||||
|
"agent.budget_updated": "updated budget for",
|
||||||
|
"agent.runtime_session_reset": "reset session for",
|
||||||
|
"heartbeat.invoked": "invoked heartbeat for",
|
||||||
|
"heartbeat.cancelled": "cancelled heartbeat for",
|
||||||
|
"approval.created": "requested approval",
|
||||||
|
"approval.approved": "approved",
|
||||||
|
"approval.rejected": "rejected",
|
||||||
|
"project.created": "created",
|
||||||
|
"project.updated": "updated",
|
||||||
|
"project.deleted": "deleted",
|
||||||
|
"goal.created": "created",
|
||||||
|
"goal.updated": "updated",
|
||||||
|
"goal.deleted": "deleted",
|
||||||
|
"cost.reported": "reported cost for",
|
||||||
|
"cost.recorded": "recorded cost for",
|
||||||
|
"company.created": "created company",
|
||||||
|
"company.updated": "updated company",
|
||||||
|
"company.archived": "archived",
|
||||||
|
"company.budget_updated": "updated budget for",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||||
|
"issue.created": "created the issue",
|
||||||
|
"issue.updated": "updated the issue",
|
||||||
|
"issue.checked_out": "checked out the issue",
|
||||||
|
"issue.released": "released the issue",
|
||||||
|
"issue.comment_added": "added a comment",
|
||||||
|
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
||||||
|
"issue.attachment_added": "added an attachment",
|
||||||
|
"issue.attachment_removed": "removed an attachment",
|
||||||
|
"issue.document_created": "created a document",
|
||||||
|
"issue.document_updated": "updated a document",
|
||||||
|
"issue.document_deleted": "deleted a document",
|
||||||
|
"issue.deleted": "deleted the issue",
|
||||||
|
"agent.created": "created an agent",
|
||||||
|
"agent.updated": "updated the agent",
|
||||||
|
"agent.paused": "paused the agent",
|
||||||
|
"agent.resumed": "resumed the agent",
|
||||||
|
"agent.terminated": "terminated the agent",
|
||||||
|
"heartbeat.invoked": "invoked a heartbeat",
|
||||||
|
"heartbeat.cancelled": "cancelled a heartbeat",
|
||||||
|
"approval.created": "requested approval",
|
||||||
|
"approval.approved": "approved",
|
||||||
|
"approval.rejected": "rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanizeValue(value: unknown): string {
|
||||||
|
if (typeof value !== "string") return String(value ?? "none");
|
||||||
|
return value.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActivityParticipant(value: unknown): value is ActivityParticipant {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record) return false;
|
||||||
|
return record.type === "agent" || record.type === "user";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActivityIssueReference(value: unknown): value is ActivityIssueReference {
|
||||||
|
return asRecord(value) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] {
|
||||||
|
const value = details?.[key];
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(isActivityParticipant);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] {
|
||||||
|
const value = details?.[key];
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(isActivityIssueReference);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUserLabel(userId: string | null | undefined, currentUserId?: string | null): string {
|
||||||
|
if (!userId || userId === "local-board") return "Board";
|
||||||
|
if (currentUserId && userId === currentUserId) return "You";
|
||||||
|
return `user ${userId.slice(0, 5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string {
|
||||||
|
if (participant.type === "agent") {
|
||||||
|
const agentId = participant.agentId ?? "";
|
||||||
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
||||||
|
}
|
||||||
|
return formatUserLabel(participant.userId, options.currentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
|
||||||
|
if (reference.identifier) return reference.identifier;
|
||||||
|
if (reference.title) return reference.title;
|
||||||
|
if (reference.id) return reference.id.slice(0, 8);
|
||||||
|
return "issue";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChangedEntityLabel(
|
||||||
|
singular: string,
|
||||||
|
plural: string,
|
||||||
|
labels: string[],
|
||||||
|
): string {
|
||||||
|
if (labels.length <= 0) return plural;
|
||||||
|
if (labels.length === 1) return `${singular} ${labels[0]}`;
|
||||||
|
return `${labels.length} ${plural}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
|
||||||
|
if (!details) return null;
|
||||||
|
const previous = asRecord(details._previous) ?? {};
|
||||||
|
if (details.status !== undefined) {
|
||||||
|
const from = previous.status;
|
||||||
|
return from
|
||||||
|
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
||||||
|
: `changed status to ${humanizeValue(details.status)} on`;
|
||||||
|
}
|
||||||
|
if (details.priority !== undefined) {
|
||||||
|
const from = previous.priority;
|
||||||
|
return from
|
||||||
|
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
||||||
|
: `changed priority to ${humanizeValue(details.priority)} on`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIssueUpdatedAction(details: ActivityDetails): string | null {
|
||||||
|
if (!details) return null;
|
||||||
|
const previous = asRecord(details._previous) ?? {};
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (details.status !== undefined) {
|
||||||
|
const from = previous.status;
|
||||||
|
parts.push(
|
||||||
|
from
|
||||||
|
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
|
||||||
|
: `changed the status to ${humanizeValue(details.status)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (details.priority !== undefined) {
|
||||||
|
const from = previous.priority;
|
||||||
|
parts.push(
|
||||||
|
from
|
||||||
|
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
|
||||||
|
: `changed the priority to ${humanizeValue(details.priority)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
||||||
|
parts.push(details.assigneeAgentId || details.assigneeUserId ? "assigned the issue" : "unassigned the issue");
|
||||||
|
}
|
||||||
|
if (details.title !== undefined) parts.push("updated the title");
|
||||||
|
if (details.description !== undefined) parts.push("updated the description");
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(", ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStructuredIssueChange(input: {
|
||||||
|
action: string;
|
||||||
|
details: ActivityDetails;
|
||||||
|
options: ActivityFormatOptions;
|
||||||
|
forIssueDetail: boolean;
|
||||||
|
}): string | null {
|
||||||
|
const details = input.details;
|
||||||
|
if (!details) return null;
|
||||||
|
|
||||||
|
if (input.action === "issue.blockers_updated") {
|
||||||
|
const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel);
|
||||||
|
const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel);
|
||||||
|
if (added.length > 0 && removed.length === 0) {
|
||||||
|
const changed = formatChangedEntityLabel("blocker", "blockers", added);
|
||||||
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
||||||
|
}
|
||||||
|
if (removed.length > 0 && added.length === 0) {
|
||||||
|
const changed = formatChangedEntityLabel("blocker", "blockers", removed);
|
||||||
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
||||||
|
}
|
||||||
|
return input.forIssueDetail ? "updated blockers" : "updated blockers on";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") {
|
||||||
|
const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
||||||
|
const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
||||||
|
const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver";
|
||||||
|
const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers";
|
||||||
|
if (added.length > 0 && removed.length === 0) {
|
||||||
|
const changed = formatChangedEntityLabel(singular, plural, added);
|
||||||
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
||||||
|
}
|
||||||
|
if (removed.length > 0 && added.length === 0) {
|
||||||
|
const changed = formatChangedEntityLabel(singular, plural, removed);
|
||||||
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
||||||
|
}
|
||||||
|
return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatActivityVerb(
|
||||||
|
action: string,
|
||||||
|
details?: Record<string, unknown> | null,
|
||||||
|
options: ActivityFormatOptions = {},
|
||||||
|
): string {
|
||||||
|
if (action === "issue.updated") {
|
||||||
|
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
|
||||||
|
if (issueUpdatedVerb) return issueUpdatedVerb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuredChange = formatStructuredIssueChange({
|
||||||
|
action,
|
||||||
|
details,
|
||||||
|
options,
|
||||||
|
forIssueDetail: false,
|
||||||
|
});
|
||||||
|
if (structuredChange) return structuredChange;
|
||||||
|
|
||||||
|
return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIssueActivityAction(
|
||||||
|
action: string,
|
||||||
|
details?: Record<string, unknown> | null,
|
||||||
|
options: ActivityFormatOptions = {},
|
||||||
|
): string {
|
||||||
|
if (action === "issue.updated") {
|
||||||
|
const issueUpdatedAction = formatIssueUpdatedAction(details);
|
||||||
|
if (issueUpdatedAction) return issueUpdatedAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuredChange = formatStructuredIssueChange({
|
||||||
|
action,
|
||||||
|
details,
|
||||||
|
options,
|
||||||
|
forIssueDetail: true,
|
||||||
|
});
|
||||||
|
if (structuredChange) return structuredChange;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
|
||||||
|
details
|
||||||
|
) {
|
||||||
|
const key = typeof details.key === "string" ? details.key : "document";
|
||||||
|
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
||||||
|
return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||||
|
}
|
||||||
|
|
@ -9,16 +9,23 @@ import {
|
||||||
describe("company routes", () => {
|
describe("company routes", () => {
|
||||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||||
|
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/issues")).toBe(true);
|
||||||
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull();
|
||||||
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe(
|
||||||
"/PAP/execution-workspaces/workspace-123",
|
"/PAP/execution-workspaces/workspace-123",
|
||||||
);
|
);
|
||||||
|
expect(applyCompanyPrefix("/execution-workspaces/workspace-123/issues", "PAP")).toBe(
|
||||||
|
"/PAP/execution-workspaces/workspace-123/issues",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
it("normalizes prefixed execution workspace paths back to company-relative paths", () => {
|
||||||
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe(
|
||||||
"/execution-workspaces/workspace-123",
|
"/execution-workspaces/workspace-123",
|
||||||
);
|
);
|
||||||
|
expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123/configuration")).toBe(
|
||||||
|
"/execution-workspaces/workspace-123/configuration",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
DEFAULT_INBOX_ISSUE_COLUMNS,
|
DEFAULT_INBOX_ISSUE_COLUMNS,
|
||||||
|
buildInboxDismissedAtByKey,
|
||||||
computeInboxBadgeData,
|
computeInboxBadgeData,
|
||||||
getAvailableInboxIssueColumns,
|
getAvailableInboxIssueColumns,
|
||||||
getApprovalsForTab,
|
getApprovalsForTab,
|
||||||
|
|
@ -19,11 +20,13 @@ import {
|
||||||
getInboxKeyboardSelectionIndex,
|
getInboxKeyboardSelectionIndex,
|
||||||
getRecentTouchedIssues,
|
getRecentTouchedIssues,
|
||||||
getUnreadTouchedIssues,
|
getUnreadTouchedIssues,
|
||||||
|
isInboxEntityDismissed,
|
||||||
isMineInboxTab,
|
isMineInboxTab,
|
||||||
loadInboxIssueColumns,
|
loadInboxIssueColumns,
|
||||||
loadLastInboxTab,
|
loadLastInboxTab,
|
||||||
normalizeInboxIssueColumns,
|
normalizeInboxIssueColumns,
|
||||||
RECENT_ISSUES_LIMIT,
|
RECENT_ISSUES_LIMIT,
|
||||||
|
resolveInboxNestingEnabled,
|
||||||
resolveIssueWorkspaceName,
|
resolveIssueWorkspaceName,
|
||||||
resolveInboxSelectionIndex,
|
resolveInboxSelectionIndex,
|
||||||
saveInboxIssueColumns,
|
saveInboxIssueColumns,
|
||||||
|
|
@ -286,7 +289,8 @@ describe("inbox helpers", () => {
|
||||||
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"),
|
||||||
],
|
],
|
||||||
mineIssues: [makeIssue("1", true)],
|
mineIssues: [makeIssue("1", true)],
|
||||||
dismissed: new Set<string>(),
|
dismissedAlerts: new Set<string>(),
|
||||||
|
dismissedAtByKey: new Map<string, number>(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -306,7 +310,8 @@ describe("inbox helpers", () => {
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")],
|
||||||
mineIssues: [],
|
mineIssues: [],
|
||||||
dismissed: new Set<string>(["run:run-1", "alert:budget", "alert:agent-errors"]),
|
dismissedAlerts: new Set<string>(["alert:budget", "alert:agent-errors"]),
|
||||||
|
dismissedAtByKey: new Map<string, number>([["run:run-1", new Date("2026-03-11T00:00:00.000Z").getTime()]]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
|
|
@ -326,7 +331,8 @@ describe("inbox helpers", () => {
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns: [],
|
heartbeatRuns: [],
|
||||||
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
|
mineIssues: [makeIssue("1", false), makeIssue("2", false), makeIssue("3", true)],
|
||||||
dismissed: new Set<string>(),
|
dismissedAlerts: new Set<string>(),
|
||||||
|
dismissedAtByKey: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.mineIssues).toBe(1);
|
expect(result.mineIssues).toBe(1);
|
||||||
|
|
@ -334,6 +340,35 @@ describe("inbox helpers", () => {
|
||||||
expect(result.inbox).toBe(3);
|
expect(result.inbox).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resurfaces non-issue items when they change after dismissal", () => {
|
||||||
|
const dismissedAtByKey = buildInboxDismissedAtByKey([
|
||||||
|
{
|
||||||
|
id: "dismissal-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
userId: "user-1",
|
||||||
|
itemKey: "approval:approval-1",
|
||||||
|
dismissedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
createdAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2026-03-11T01:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isInboxEntityDismissed(
|
||||||
|
dismissedAtByKey,
|
||||||
|
"approval:approval-1",
|
||||||
|
new Date("2026-03-11T00:30:00.000Z"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isInboxEntityDismissed(
|
||||||
|
dismissedAtByKey,
|
||||||
|
"approval:approval-1",
|
||||||
|
new Date("2026-03-11T01:30:00.000Z"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps read issues in the touched list but excludes them from unread counts", () => {
|
it("keeps read issues in the touched list but excludes them from unread counts", () => {
|
||||||
const issues = [makeIssue("1", true), makeIssue("2", false)];
|
const issues = [makeIssue("1", true), makeIssue("2", false)];
|
||||||
|
|
||||||
|
|
@ -518,6 +553,19 @@ describe("inbox helpers", () => {
|
||||||
expect(loadLastInboxTab()).toBe("all");
|
expect(loadLastInboxTab()).toBe("all");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps nesting enabled on desktop when the saved preference is on", () => {
|
||||||
|
expect(resolveInboxNestingEnabled(true, false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forces nesting off on mobile even when the saved preference is on", () => {
|
||||||
|
expect(resolveInboxNestingEnabled(true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps nesting off when the saved preference is off", () => {
|
||||||
|
expect(resolveInboxNestingEnabled(false, false)).toBe(false);
|
||||||
|
expect(resolveInboxNestingEnabled(false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("defaults issue columns to the current inbox layout", () => {
|
it("defaults issue columns to the current inbox layout", () => {
|
||||||
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
|
expect(loadInboxIssueColumns()).toEqual(DEFAULT_INBOX_ISSUE_COLUMNS);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import type { Approval, DashboardSummary, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type {
|
||||||
|
Approval,
|
||||||
|
DashboardSummary,
|
||||||
|
HeartbeatRun,
|
||||||
|
InboxDismissal,
|
||||||
|
Issue,
|
||||||
|
JoinRequest,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
|
|
||||||
export const RECENT_ISSUES_LIMIT = 100;
|
export const RECENT_ISSUES_LIMIT = 100;
|
||||||
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||||
|
|
@ -7,6 +14,7 @@ export const DISMISSED_KEY = "paperclip:inbox:dismissed";
|
||||||
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
export const READ_ITEMS_KEY = "paperclip:inbox:read-items";
|
||||||
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab";
|
||||||
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
export const INBOX_ISSUE_COLUMNS_KEY = "paperclip:inbox:issue-columns";
|
||||||
|
export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
|
||||||
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
export type InboxTab = "mine" | "recent" | "unread" | "all";
|
||||||
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
export type InboxApprovalFilter = "all" | "actionable" | "resolved";
|
||||||
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
export const inboxIssueColumns = ["status", "id", "assignee", "project", "workspace", "parent", "labels", "updated"] as const;
|
||||||
|
|
@ -43,16 +51,19 @@ export interface InboxBadgeData {
|
||||||
alerts: number;
|
alerts: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadDismissedInboxItems(): Set<string> {
|
export function loadDismissedInboxAlerts(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(DISMISSED_KEY);
|
const raw = localStorage.getItem(DISMISSED_KEY);
|
||||||
return raw ? new Set(JSON.parse(raw)) : new Set();
|
if (!raw) return new Set();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return new Set();
|
||||||
|
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.startsWith("alert:")));
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveDismissedInboxItems(ids: Set<string>) {
|
export function saveDismissedInboxAlerts(ids: Set<string>) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...ids]));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -60,6 +71,22 @@ export function saveDismissedInboxItems(ids: Set<string>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildInboxDismissedAtByKey(dismissals: InboxDismissal[]): Map<string, number> {
|
||||||
|
return new Map(
|
||||||
|
dismissals.map((dismissal) => [dismissal.itemKey, normalizeTimestamp(dismissal.dismissedAt)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInboxEntityDismissed(
|
||||||
|
dismissedAtByKey: ReadonlyMap<string, number>,
|
||||||
|
itemKey: string,
|
||||||
|
activityAt: string | Date | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
const dismissedAt = dismissedAtByKey.get(itemKey);
|
||||||
|
if (dismissedAt == null) return false;
|
||||||
|
return dismissedAt >= normalizeTimestamp(activityAt);
|
||||||
|
}
|
||||||
|
|
||||||
export function loadReadInboxItems(): Set<string> {
|
export function loadReadInboxItems(): Set<string> {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(READ_ITEMS_KEY);
|
const raw = localStorage.getItem(READ_ITEMS_KEY);
|
||||||
|
|
@ -151,6 +178,27 @@ export function resolveIssueWorkspaceName(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadInboxNesting(): boolean {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(INBOX_NESTING_KEY);
|
||||||
|
return raw !== "false";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveInboxNesting(enabled: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(INBOX_NESTING_KEY, String(enabled));
|
||||||
|
} catch {
|
||||||
|
// Ignore localStorage failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInboxNestingEnabled(preferenceEnabled: boolean, isMobile: boolean): boolean {
|
||||||
|
return preferenceEnabled && !isMobile;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadLastInboxTab(): InboxTab {
|
export function loadLastInboxTab(): InboxTab {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
const raw = localStorage.getItem(INBOX_LAST_TAB_KEY);
|
||||||
|
|
@ -314,6 +362,68 @@ export function getInboxWorkItems({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups parent-child issues in a flat InboxWorkItem list.
|
||||||
|
*
|
||||||
|
* - Children whose parent is also in the list are removed from the top level
|
||||||
|
* and stored in `childrenByIssueId`.
|
||||||
|
* - The parent's sort timestamp becomes max(parent, children) so that a group
|
||||||
|
* with a recently-updated child floats to the top.
|
||||||
|
* - If a parent is absent (e.g. archived), children remain as independent roots.
|
||||||
|
*/
|
||||||
|
export function buildInboxNesting(items: InboxWorkItem[]): {
|
||||||
|
displayItems: InboxWorkItem[];
|
||||||
|
childrenByIssueId: Map<string, Issue[]>;
|
||||||
|
} {
|
||||||
|
const issueItems: (InboxWorkItem & { kind: "issue" })[] = [];
|
||||||
|
const nonIssueItems: InboxWorkItem[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind === "issue") issueItems.push(item as InboxWorkItem & { kind: "issue" });
|
||||||
|
else nonIssueItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueIdSet = new Set(issueItems.map((i) => i.issue.id));
|
||||||
|
const childrenByIssueId = new Map<string, Issue[]>();
|
||||||
|
const childIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const item of issueItems) {
|
||||||
|
const { issue } = item;
|
||||||
|
if (issue.parentId && issueIdSet.has(issue.parentId)) {
|
||||||
|
childIds.add(issue.id);
|
||||||
|
const arr = childrenByIssueId.get(issue.parentId) ?? [];
|
||||||
|
arr.push(issue);
|
||||||
|
childrenByIssueId.set(issue.parentId, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each child list by most recent activity
|
||||||
|
for (const children of childrenByIssueId.values()) {
|
||||||
|
children.sort(sortIssuesByMostRecentActivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build root issue items with group-adjusted timestamps
|
||||||
|
const rootIssueItems: InboxWorkItem[] = issueItems
|
||||||
|
.filter((item) => !childIds.has(item.issue.id))
|
||||||
|
.map((item) => {
|
||||||
|
const children = childrenByIssueId.get(item.issue.id);
|
||||||
|
if (!children?.length) return item;
|
||||||
|
const maxChildTs = Math.max(...children.map(issueLastActivityTimestamp));
|
||||||
|
return { ...item, timestamp: Math.max(item.timestamp, maxChildTs) };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge and re-sort
|
||||||
|
const displayItems = [...rootIssueItems, ...nonIssueItems].sort((a, b) => {
|
||||||
|
const diff = b.timestamp - a.timestamp;
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
if (a.kind === "issue" && b.kind === "issue") {
|
||||||
|
return sortIssuesByMostRecentActivity(a.issue, b.issue);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { displayItems, childrenByIssueId };
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldShowInboxSection({
|
export function shouldShowInboxSection({
|
||||||
tab,
|
tab,
|
||||||
hasItems,
|
hasItems,
|
||||||
|
|
@ -342,25 +452,27 @@ export function computeInboxBadgeData({
|
||||||
dashboard,
|
dashboard,
|
||||||
heartbeatRuns,
|
heartbeatRuns,
|
||||||
mineIssues,
|
mineIssues,
|
||||||
dismissed,
|
dismissedAlerts,
|
||||||
|
dismissedAtByKey,
|
||||||
}: {
|
}: {
|
||||||
approvals: Approval[];
|
approvals: Approval[];
|
||||||
joinRequests: JoinRequest[];
|
joinRequests: JoinRequest[];
|
||||||
dashboard: DashboardSummary | undefined;
|
dashboard: DashboardSummary | undefined;
|
||||||
heartbeatRuns: HeartbeatRun[];
|
heartbeatRuns: HeartbeatRun[];
|
||||||
mineIssues: Issue[];
|
mineIssues: Issue[];
|
||||||
dismissed: Set<string>;
|
dismissedAlerts: Set<string>;
|
||||||
|
dismissedAtByKey: ReadonlyMap<string, number>;
|
||||||
}): InboxBadgeData {
|
}): InboxBadgeData {
|
||||||
const actionableApprovals = approvals.filter(
|
const actionableApprovals = approvals.filter(
|
||||||
(approval) =>
|
(approval) =>
|
||||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
ACTIONABLE_APPROVAL_STATUSES.has(approval.status) &&
|
||||||
!dismissed.has(`approval:${approval.id}`),
|
!isInboxEntityDismissed(dismissedAtByKey, `approval:${approval.id}`, approval.updatedAt),
|
||||||
).length;
|
).length;
|
||||||
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter(
|
||||||
(run) => !dismissed.has(`run:${run.id}`),
|
(run) => !isInboxEntityDismissed(dismissedAtByKey, `run:${run.id}`, run.createdAt),
|
||||||
).length;
|
).length;
|
||||||
const visibleJoinRequests = joinRequests.filter(
|
const visibleJoinRequests = joinRequests.filter(
|
||||||
(jr) => !dismissed.has(`join:${jr.id}`),
|
(jr) => !isInboxEntityDismissed(dismissedAtByKey, `join:${jr.id}`, jr.updatedAt ?? jr.createdAt),
|
||||||
).length;
|
).length;
|
||||||
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
|
const visibleMineIssues = mineIssues.filter((issue) => issue.isUnreadForMe).length;
|
||||||
const agentErrorCount = dashboard?.agents.error ?? 0;
|
const agentErrorCount = dashboard?.agents.error ?? 0;
|
||||||
|
|
@ -369,11 +481,11 @@ export function computeInboxBadgeData({
|
||||||
const showAggregateAgentError =
|
const showAggregateAgentError =
|
||||||
agentErrorCount > 0 &&
|
agentErrorCount > 0 &&
|
||||||
failedRuns === 0 &&
|
failedRuns === 0 &&
|
||||||
!dismissed.has("alert:agent-errors");
|
!dismissedAlerts.has("alert:agent-errors");
|
||||||
const showBudgetAlert =
|
const showBudgetAlert =
|
||||||
monthBudgetCents > 0 &&
|
monthBudgetCents > 0 &&
|
||||||
monthUtilizationPercent >= 80 &&
|
monthUtilizationPercent >= 80 &&
|
||||||
!dismissed.has("alert:budget");
|
!dismissedAlerts.has("alert:budget");
|
||||||
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,43 @@ describe("buildAssistantPartsFromTranscript", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats a completed tool-only segment as resolved once a tool_result arrives", () => {
|
||||||
|
const result = buildAssistantPartsFromTranscript([
|
||||||
|
{ kind: "thinking", ts: "2026-04-06T12:00:00.000Z", text: "Checking the task." },
|
||||||
|
{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts: "2026-04-06T12:00:01.000Z",
|
||||||
|
name: "search",
|
||||||
|
toolUseId: "tool-1",
|
||||||
|
input: { query: "paperclip" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts: "2026-04-06T12:00:02.000Z",
|
||||||
|
toolUseId: "tool-1",
|
||||||
|
content: "search completed",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{ kind: "assistant", ts: "2026-04-06T12:00:03.000Z", text: "Found the relevant code." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.parts).toMatchObject([
|
||||||
|
{ type: "reasoning", text: "Checking the task." },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "tool-1",
|
||||||
|
toolName: "search",
|
||||||
|
result: "search completed",
|
||||||
|
isError: false,
|
||||||
|
},
|
||||||
|
{ type: "text", text: "Found the relevant code." },
|
||||||
|
]);
|
||||||
|
expect(result.segments).toEqual([{
|
||||||
|
startMs: new Date("2026-04-06T12:00:00.000Z").getTime(),
|
||||||
|
endMs: new Date("2026-04-06T12:00:02.000Z").getTime(),
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps run errors while suppressing init and system transcript noise", () => {
|
it("keeps run errors while suppressing init and system transcript noise", () => {
|
||||||
const result = buildAssistantPartsFromTranscript([
|
const result = buildAssistantPartsFromTranscript([
|
||||||
{
|
{
|
||||||
|
|
@ -270,7 +307,7 @@ describe("buildIssueChatMessages", () => {
|
||||||
"system:activity:event-1",
|
"system:activity:event-1",
|
||||||
"user:comment-1",
|
"user:comment-1",
|
||||||
"assistant:comment-2",
|
"assistant:comment-2",
|
||||||
"assistant:live-run:run-live-1",
|
"assistant:run-assistant:run-live-1",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const liveRunMessage = messages.at(-1);
|
const liveRunMessage = messages.at(-1);
|
||||||
|
|
@ -316,7 +353,7 @@ describe("buildIssueChatMessages", () => {
|
||||||
|
|
||||||
expect(messages).toHaveLength(1);
|
expect(messages).toHaveLength(1);
|
||||||
expect(messages[0]).toMatchObject({
|
expect(messages[0]).toMatchObject({
|
||||||
id: "historical-run:run-history-1",
|
id: "run-assistant:run-history-1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
status: { type: "complete", reason: "stop" },
|
status: { type: "complete", reason: "stop" },
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|
@ -333,6 +370,64 @@ describe("buildIssueChatMessages", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the same assistant message id when a live run becomes a cancelled historical run", () => {
|
||||||
|
const liveMessages = buildIssueChatMessages({
|
||||||
|
comments: [],
|
||||||
|
timelineEvents: [],
|
||||||
|
linkedRuns: [],
|
||||||
|
liveRuns: [
|
||||||
|
{
|
||||||
|
id: "run-1",
|
||||||
|
status: "running",
|
||||||
|
invocationSource: "manual",
|
||||||
|
triggerDetail: null,
|
||||||
|
startedAt: "2026-04-06T12:01:00.000Z",
|
||||||
|
finishedAt: null,
|
||||||
|
createdAt: "2026-04-06T12:01:00.000Z",
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "CodexCoder",
|
||||||
|
adapterType: "codex_local",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transcriptsByRunId: new Map([
|
||||||
|
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||||
|
]),
|
||||||
|
hasOutputForRun: (runId) => runId === "run-1",
|
||||||
|
currentUserId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelledMessages = buildIssueChatMessages({
|
||||||
|
comments: [],
|
||||||
|
timelineEvents: [],
|
||||||
|
linkedRuns: [
|
||||||
|
{
|
||||||
|
runId: "run-1",
|
||||||
|
status: "cancelled",
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentName: "CodexCoder",
|
||||||
|
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
startedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||||
|
finishedAt: new Date("2026-04-06T12:01:08.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
liveRuns: [],
|
||||||
|
transcriptsByRunId: new Map([
|
||||||
|
["run-1", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]],
|
||||||
|
]),
|
||||||
|
hasOutputForRun: (runId) => runId === "run-1",
|
||||||
|
currentUserId: "user-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(liveMessages).toHaveLength(1);
|
||||||
|
expect(cancelledMessages).toHaveLength(1);
|
||||||
|
expect(liveMessages[0]).toMatchObject({ id: "run-assistant:run-1", status: { type: "running" } });
|
||||||
|
expect(cancelledMessages[0]).toMatchObject({
|
||||||
|
id: "run-assistant:run-1",
|
||||||
|
status: { type: "complete", reason: "stop" },
|
||||||
|
metadata: { custom: { runStatus: "cancelled" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
|
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
|
||||||
const messages = buildIssueChatMessages({
|
const messages = buildIssueChatMessages({
|
||||||
comments: [],
|
comments: [],
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ function createHistoricalTranscriptMessage(args: {
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const message: ThreadAssistantMessage = {
|
const message: ThreadAssistantMessage = {
|
||||||
id: `historical-run:${run.runId}`,
|
id: `run-assistant:${run.runId}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
createdAt: toDate(run.startedAt ?? run.createdAt),
|
createdAt: toDate(run.startedAt ?? run.createdAt),
|
||||||
content,
|
content,
|
||||||
|
|
@ -593,25 +593,20 @@ function normalizeLiveRuns(
|
||||||
function createLiveRunMessage(args: {
|
function createLiveRunMessage(args: {
|
||||||
run: LiveRunForIssue;
|
run: LiveRunForIssue;
|
||||||
transcript: readonly IssueChatTranscriptEntry[];
|
transcript: readonly IssueChatTranscriptEntry[];
|
||||||
hasOutput: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const { run, transcript, hasOutput } = args;
|
const { run, transcript } = args;
|
||||||
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
||||||
const waitingText =
|
const waitingText =
|
||||||
run.status === "queued"
|
run.status === "queued"
|
||||||
? "Queued..."
|
? "Queued..."
|
||||||
: hasOutput
|
: parts.length > 0
|
||||||
? ""
|
? ""
|
||||||
: "Working...";
|
: "Working...";
|
||||||
|
|
||||||
const content = parts.length > 0
|
const content = parts;
|
||||||
? parts
|
|
||||||
: waitingText
|
|
||||||
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const message: ThreadAssistantMessage = {
|
const message: ThreadAssistantMessage = {
|
||||||
id: `live-run:${run.id}`,
|
id: `run-assistant:${run.id}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
createdAt: toDate(run.startedAt ?? run.createdAt),
|
createdAt: toDate(run.startedAt ?? run.createdAt),
|
||||||
content,
|
content,
|
||||||
|
|
@ -684,7 +679,10 @@ export function buildIssueChatMessages(args: {
|
||||||
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
|
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
|
||||||
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
|
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
|
||||||
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
|
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
|
||||||
if (hasRunOutput) {
|
if (hasRunOutput || run.status !== "succeeded") {
|
||||||
|
// Always use the transcript message for non-succeeded runs (even before
|
||||||
|
// transcript data loads) so the message type and fold header are stable
|
||||||
|
// from initial render — avoids a flash when transcripts arrive later.
|
||||||
orderedMessages.push({
|
orderedMessages.push({
|
||||||
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
|
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
|
||||||
order: 2,
|
order: 2,
|
||||||
|
|
@ -697,7 +695,7 @@ export function buildIssueChatMessages(args: {
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (run.status === "succeeded" && !includeSucceededRunsWithoutOutput) continue;
|
if (!includeSucceededRunsWithoutOutput) continue;
|
||||||
orderedMessages.push({
|
orderedMessages.push({
|
||||||
createdAtMs: toTimestamp(runTimestamp(run)),
|
createdAtMs: toTimestamp(runTimestamp(run)),
|
||||||
order: 2,
|
order: 2,
|
||||||
|
|
@ -712,7 +710,6 @@ export function buildIssueChatMessages(args: {
|
||||||
message: createLiveRunMessage({
|
message: createLiveRunMessage({
|
||||||
run,
|
run,
|
||||||
transcript: transcriptsByRunId?.get(run.id) ?? [],
|
transcript: transcriptsByRunId?.get(run.id) ?? [],
|
||||||
hasOutput: hasOutputForRun?.(run.id) ?? false,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
hasBlockingShortcutDialog,
|
hasBlockingShortcutDialog,
|
||||||
isKeyboardShortcutTextInputTarget,
|
isKeyboardShortcutTextInputTarget,
|
||||||
|
resolveIssueDetailGoKeyAction,
|
||||||
resolveInboxQuickArchiveKeyAction,
|
resolveInboxQuickArchiveKeyAction,
|
||||||
} from "./keyboardShortcuts";
|
} from "./keyboardShortcuts";
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
})).toBe("archive");
|
})).toBe("archive");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disarms on the first non-y keypress", () => {
|
it("ignores non-y keypresses", () => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
|
|
||||||
expect(resolveInboxQuickArchiveKeyAction({
|
expect(resolveInboxQuickArchiveKeyAction({
|
||||||
|
|
@ -66,7 +67,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
altKey: false,
|
altKey: false,
|
||||||
target: button,
|
target: button,
|
||||||
hasOpenDialog: false,
|
hasOpenDialog: false,
|
||||||
})).toBe("disarm");
|
})).toBe("ignore");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays inert for modifier combos before a real keypress", () => {
|
it("stays inert for modifier combos before a real keypress", () => {
|
||||||
|
|
@ -95,7 +96,7 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
})).toBe("ignore");
|
})).toBe("ignore");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disarms instead of archiving when typing into an editor", () => {
|
it("ignores input typing instead of archiving", () => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
|
|
||||||
expect(resolveInboxQuickArchiveKeyAction({
|
expect(resolveInboxQuickArchiveKeyAction({
|
||||||
|
|
@ -107,6 +108,66 @@ describe("keyboardShortcuts helpers", () => {
|
||||||
altKey: false,
|
altKey: false,
|
||||||
target: input,
|
target: input,
|
||||||
hasOpenDialog: false,
|
hasOpenDialog: false,
|
||||||
|
})).toBe("ignore");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arms go-to-inbox on a clean g press", () => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
expect(resolveIssueDetailGoKeyAction({
|
||||||
|
armed: false,
|
||||||
|
defaultPrevented: false,
|
||||||
|
key: "g",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
target: button,
|
||||||
|
hasOpenDialog: false,
|
||||||
|
})).toBe("arm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to inbox on i after g", () => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
expect(resolveIssueDetailGoKeyAction({
|
||||||
|
armed: true,
|
||||||
|
defaultPrevented: false,
|
||||||
|
key: "i",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
target: button,
|
||||||
|
hasOpenDialog: false,
|
||||||
|
})).toBe("navigate_inbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses the comment composer on c after g", () => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
expect(resolveIssueDetailGoKeyAction({
|
||||||
|
armed: true,
|
||||||
|
defaultPrevented: false,
|
||||||
|
key: "c",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
target: button,
|
||||||
|
hasOpenDialog: false,
|
||||||
|
})).toBe("focus_comment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disarms go-to-inbox instead of firing from an editor", () => {
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
|
||||||
|
expect(resolveIssueDetailGoKeyAction({
|
||||||
|
armed: true,
|
||||||
|
defaultPrevented: false,
|
||||||
|
key: "i",
|
||||||
|
metaKey: false,
|
||||||
|
ctrlKey: false,
|
||||||
|
altKey: false,
|
||||||
|
target: input,
|
||||||
|
hasOpenDialog: false,
|
||||||
})).toBe("disarm");
|
})).toBe("disarm");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
||||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||||
|
|
||||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||||
|
export type IssueDetailGoKeyAction = "ignore" | "arm" | "navigate_inbox" | "focus_comment" | "disarm";
|
||||||
|
|
||||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
|
@ -46,9 +47,42 @@ export function resolveInboxQuickArchiveKeyAction({
|
||||||
hasOpenDialog: boolean;
|
hasOpenDialog: boolean;
|
||||||
}): InboxQuickArchiveKeyAction {
|
}): InboxQuickArchiveKeyAction {
|
||||||
if (!armed) return "ignore";
|
if (!armed) return "ignore";
|
||||||
if (defaultPrevented) return "disarm";
|
if (defaultPrevented) return "ignore";
|
||||||
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
|
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "ignore";
|
||||||
if (key === "y") return "archive";
|
if (key.toLowerCase() === "y") return "archive";
|
||||||
|
return "ignore";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIssueDetailGoKeyAction({
|
||||||
|
armed,
|
||||||
|
defaultPrevented,
|
||||||
|
key,
|
||||||
|
metaKey,
|
||||||
|
ctrlKey,
|
||||||
|
altKey,
|
||||||
|
target,
|
||||||
|
hasOpenDialog,
|
||||||
|
}: {
|
||||||
|
armed: boolean;
|
||||||
|
defaultPrevented: boolean;
|
||||||
|
key: string;
|
||||||
|
metaKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
altKey: boolean;
|
||||||
|
target: EventTarget | null;
|
||||||
|
hasOpenDialog: boolean;
|
||||||
|
}): IssueDetailGoKeyAction {
|
||||||
|
if (defaultPrevented) return armed ? "disarm" : "ignore";
|
||||||
|
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||||
|
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) {
|
||||||
|
return armed ? "disarm" : "ignore";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
if (!armed) return normalizedKey === "g" ? "arm" : "ignore";
|
||||||
|
if (normalizedKey === "i") return "navigate_inbox";
|
||||||
|
if (normalizedKey === "c") return "focus_comment";
|
||||||
|
if (normalizedKey === "g") return "arm";
|
||||||
return "disarm";
|
return "disarm";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
ui/src/lib/main-content-focus.test.ts
Normal file
66
ui/src/lib/main-content-focus.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
scheduleMainContentFocus,
|
||||||
|
shouldFocusMainContentAfterNavigation,
|
||||||
|
} from "./main-content-focus";
|
||||||
|
|
||||||
|
describe("main-content-focus", () => {
|
||||||
|
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
|
||||||
|
let originalCancelAnimationFrame: typeof window.cancelAnimationFrame;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
|
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
|
||||||
|
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
|
||||||
|
window.cancelAnimationFrame = ((handle: number) => window.clearTimeout(handle)) as typeof window.cancelAnimationFrame;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.requestAnimationFrame = originalRequestAnimationFrame;
|
||||||
|
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the main content when navigation leaves focus outside it", async () => {
|
||||||
|
const sidebarButton = document.createElement("button");
|
||||||
|
const main = document.createElement("main");
|
||||||
|
main.tabIndex = -1;
|
||||||
|
document.body.append(sidebarButton, main);
|
||||||
|
sidebarButton.focus();
|
||||||
|
|
||||||
|
scheduleMainContentFocus(main);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(main);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not steal focus from an active element already inside main content", async () => {
|
||||||
|
const main = document.createElement("main");
|
||||||
|
const input = document.createElement("input");
|
||||||
|
main.tabIndex = -1;
|
||||||
|
main.appendChild(input);
|
||||||
|
document.body.append(main);
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
scheduleMainContentFocus(main);
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats disconnected elements as needing main-content focus", () => {
|
||||||
|
const main = document.createElement("main");
|
||||||
|
main.tabIndex = -1;
|
||||||
|
document.body.append(main);
|
||||||
|
|
||||||
|
const staleButton = document.createElement("button");
|
||||||
|
staleButton.focus();
|
||||||
|
|
||||||
|
expect(shouldFocusMainContentAfterNavigation(main, staleButton)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
ui/src/lib/main-content-focus.ts
Normal file
21
ui/src/lib/main-content-focus.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
export function shouldFocusMainContentAfterNavigation(
|
||||||
|
mainElement: HTMLElement | null,
|
||||||
|
activeElement: Element | null,
|
||||||
|
): boolean {
|
||||||
|
if (!(mainElement instanceof HTMLElement)) return false;
|
||||||
|
if (!(activeElement instanceof HTMLElement)) return true;
|
||||||
|
if (!document.contains(activeElement)) return true;
|
||||||
|
if (activeElement === document.body || activeElement === document.documentElement) return true;
|
||||||
|
return !mainElement.contains(activeElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleMainContentFocus(mainElement: HTMLElement | null): () => void {
|
||||||
|
if (!(mainElement instanceof HTMLElement)) return () => {};
|
||||||
|
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
if (!shouldFocusMainContentAfterNavigation(mainElement, document.activeElement)) return;
|
||||||
|
mainElement.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
34
ui/src/lib/new-agent-runtime-config.test.ts
Normal file
34
ui/src/lib/new-agent-runtime-config.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildNewAgentRuntimeConfig } from "./new-agent-runtime-config";
|
||||||
|
|
||||||
|
describe("buildNewAgentRuntimeConfig", () => {
|
||||||
|
it("defaults new agents to no timer heartbeat", () => {
|
||||||
|
expect(buildNewAgentRuntimeConfig()).toEqual({
|
||||||
|
heartbeat: {
|
||||||
|
enabled: false,
|
||||||
|
intervalSec: 300,
|
||||||
|
wakeOnDemand: true,
|
||||||
|
cooldownSec: 10,
|
||||||
|
maxConcurrentRuns: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves explicit heartbeat settings", () => {
|
||||||
|
expect(
|
||||||
|
buildNewAgentRuntimeConfig({
|
||||||
|
heartbeatEnabled: true,
|
||||||
|
intervalSec: 3600,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
heartbeat: {
|
||||||
|
enabled: true,
|
||||||
|
intervalSec: 3600,
|
||||||
|
wakeOnDemand: true,
|
||||||
|
cooldownSec: 10,
|
||||||
|
maxConcurrentRuns: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue