Compare commits

...
Sign in to create a new pull request.

38 commits

Author SHA1 Message Date
dotta
b1b3408efa Restrict sidebar reordering to mouse input
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
57357991e4 Set inbox selection to fixed light gray
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
50577b8c63 Neutralize selected inbox accents
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
1871a602df Align inbox non-issue selection styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
facf994694 Align inbox click selection styling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
403aeff7f6 Refine mine inbox shortcut behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
7d81e4cb2a Fix mine inbox keyboard selection
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
44f052f4c5 Fix inbox selection highlight to show on individual items
Replace outline approach (blended with card border, invisible) with:
- 3px blue left-border bar (absolute positioned, like Gmail)
- Subtle tinted background with forced transparent children so the
  highlight shows through opaque child backgrounds

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
c33dcbd202 Fix keyboard shortcuts using refs to avoid stale closures
Refactored keyboard handler to use refs (kbStateRef, kbActionsRef) for
all mutable state and actions. This ensures the single stable event
listener always reads fresh values instead of relying on effect
dependency re-registration which could miss updates.

Also fixed selection highlight visibility: replaced bg-accent (too
subtle) with bg-primary/10 + outline-primary/30 which is clearly
visible in both light and dark modes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
bc61eb84df Remove comment composer interrupt checkbox
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
74687553f3 Improve queued comment thread UX
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
4226e15128 Add issue comment interrupt support
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
cfb7dd4818 Harden optimistic comment IDs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
52bb4ea37a Add optimistic issue comment rendering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
3986eb615c fix(ui): harden issue breadcrumb source routing
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
0f9faa297b Style markdown links with underline and pointer cursor
Links in both rendered markdown (.paperclip-markdown) and the MDXEditor
(.paperclip-mdxeditor-content) now display with underline text-decoration
and cursor:pointer by default. Mention chips are excluded from underline
styling to preserve their pill appearance.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:57:34 -05:00
dotta
d917375e35 Fix invisible keyboard selection highlight in inbox
Replace ring-2 outline (clipped by overflow-hidden container) with
bg-accent background color for the selected item. Visible in both
light and dark modes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
ce4536d1fa Add agent Mine inbox API surface
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
4fd62a3d91 fix: prevent 'Mark all as read' from wrapping on mobile
Restructured the inbox header layout to always keep tabs and the
button on the same row using flex justify-between (no responsive
column stacking). Filter dropdowns for the All tab are now on a
separate row below.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
25066c967b fix: clamp mention dropdown position to viewport on mobile
The portal-rendered mention dropdown could appear off-screen on mobile
devices. Clamp top/left to keep it within the viewport and cap width
to 100vw - 16px.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
1534b39ee3 Move 'Mark all as read' button to top-right of inbox header
Moved the button out of the tabs wrapper and into the right-side flex
container so it aligns to the right instead of wrapping below the tabs.
The button now sits alongside the filter dropdowns (on the All tab) or
alone on the right (on other tabs).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
826da2973d Tighten mine-only inbox swipe archive
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
4426d96610 Restrict inbox keyboard shortcuts to mine tab only
All keyboard shortcuts (j/k/a/y/U/r/Enter) now only fire when the
user is on the "Mine" tab. Previously j/k and other navigation
shortcuts were active on all tabs.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
c8956094ad Add y as inbox archive shortcut alongside a
Both a and y now archive the selected item in the mine tab.
Archive requires selecting an item first with j/k navigation.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
2ec4ba629e Add mail-client keyboard shortcuts to inbox mine tab
j/k navigate up/down, a to archive, U to mark unread, r to mark read,
Enter to open. Includes server-side DELETE /issues/:id/read endpoint
for mark-unread support on issues.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
182b459235 Add "Today" divider line in inbox between recent and older items
Shows a dark gray horizontal line with "Today" label on the right,
vertically centered, between items from the last 24 hours and older
items. Applies to all inbox tabs (Mine, Recent, Unread, All).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
dotta
94d6ae4049 Fix inbox swipe-to-archive click-through
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-29 10:57:34 -05:00
Dotta
6a72faf83b
Merge pull request #1949 from vanductai/fix/dev-watch-tsx-cli-path
Some checks failed
Docker / build-and-push (push) Has been cancelled
Refresh Lockfile / refresh (push) Has been cancelled
Release / verify_canary (push) Has been cancelled
Release / verify_stable (push) Has been cancelled
Release / publish_canary (push) Has been cancelled
Release / preview_stable (push) Has been cancelled
Release / publish_stable (push) Has been cancelled
fix(server): use stable tsx/cli entry point in dev-watch
2026-03-28 16:45:04 -05:00
Dotta
1fd40920db
Merge pull request #1974 from paperclipai/chore/refresh-lockfile
chore(lockfile): refresh pnpm-lock.yaml
2026-03-28 06:50:53 -05:00
lockfile-bot
caef115b95 chore(lockfile): refresh pnpm-lock.yaml 2026-03-28 11:46:21 +00:00
Dotta
17e5322e28
Merge pull request #1955 from HenkDz/feat/hermes-adapter-upgrade
feat(hermes): upgrade hermes-paperclip-adapter + UI adapter, skills, model detection
2026-03-28 06:46:01 -05:00
HenkDz
582f4ceaf4 fix: address Hermes adapter review feedback 2026-03-28 11:35:58 +01:00
HenkDz
1583a2d65a feat(hermes): upgrade hermes-paperclip-adapter + UI adapter + skills + detectModel
Upgrades hermes-paperclip-adapter from 0.1.1 to ^0.2.0 and wires in all new
capabilities introduced in v0.2.0:

Server
- Upgrade hermes-paperclip-adapter 0.1.1 -> ^0.2.0 (pending PR#10 merge)
- Wire listSkills + syncSkills from hermes-paperclip-adapter/server
- Add detectModel to hermesLocalAdapter (reads ~/.hermes/config.yaml)
- Add detectAdapterModel() function + /adapters/:type/detect-model route
- Export detectAdapterModel from server/src/adapters/index.ts

Types
- Add optional detectModel? to ServerAdapterModule in adapter-utils

UI
- Add hermes-paperclip-adapter ^0.2.0 to ui/package.json (for /ui exports)
- New ui/src/adapters/hermes-local/ — config fields + UI adapter module
- Register hermesLocalUIAdapter in UI adapter registry
- New HermesIcon (caduceus SVG) for adapter pickers
- AgentConfigForm: detect-model button, creatable model input, preserve
  adapter-agnostic fields (env, promptTemplate) when switching adapter type
- NewAgentDialog + OnboardingWizard: add Hermes to adapter picker
- Agents, OrgChart, InviteLanding, NewAgent, agent-config-primitives: add
  hermes_local label + enable in adapter sets
- AgentDetail: smarter run summary excerpt extraction
- RunTranscriptView: improved Hermes stdout rendering

NOTE: requires hermes-paperclip-adapter@0.2.0 on npm.
      Blocked on NousResearch/hermes-paperclip-adapter#10 merging.
2026-03-28 01:34:48 +01:00
vanductai
9a70a4edaa fix(server): use stable tsx/cli entry point in dev-watch
The dev-watch script was importing tsx via the internal path
'tsx/dist/cli.mjs', which is an undocumented implementation detail
that broke when tsx updated its internal structure.

Switched to the stable public export 'tsx/cli' which is the
officially supported entry point and won't break across versions.
2026-03-28 06:42:03 +07:00
Dotta
0ac01a04e5
Merge pull request #1891 from paperclipai/docs/maintenance-20260327-public
docs: documentation accuracy update 2026-03-27
2026-03-27 07:47:24 -05:00
dotta
11ff24cd22 docs: fix adapter type references and complete adapter table
- Fix openclaw → openclaw_gateway type key in adapters overview and managing-agents guide
- Add missing adapters to overview table: hermes_local, cursor, pi_local
- Mark gemini_local as experimental (adapter package exists but not in stable type enum)
- Update "Choosing an Adapter" recommendations to match stable adapter set

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:05:08 -05:00
Devin Foley
a5d47166e2
docs: add board-operator delegation guide (#1889)
* docs: add board-operator delegation guide

Create docs/guides/board-operator/delegation.md explaining the full
CEO-led delegation lifecycle from the board operator's perspective.
Covers what the board needs to do, what the CEO automates, common
delegation patterns (flat, 3-level, hire-on-demand), and a
troubleshooting section that directly answers the #1 new-user
confusion point: "Do I have to tell the CEO to delegate?"

Also adds a Delegation section to core-concepts.md and wires the
new guide into docs.json navigation after Managing Tasks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs: add AGENTS.md troubleshooting note to delegation guide

Add a row to the troubleshooting table telling board operators to
verify the CEO's AGENTS.md instructions file contains delegation
directives. Without these instructions, the CEO won't delegate.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* docs: fix stale concept count and frontmatter summary

Update "five key concepts" to "six" and add "delegation" to the
frontmatter summary field, addressing Greptile review comments.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-03-26 23:01:58 -07:00
Dotta
af5b980362
Merge pull request #1857 from paperclipai/PAP-878-create-a-mine-tab-in-inbox
Add a Mine tab and archive flow to inbox
2026-03-26 16:21:47 -05:00
61 changed files with 2891 additions and 319 deletions

View file

@ -20,9 +20,12 @@ When a heartbeat fires, Paperclip:
|---------|----------|-------------| |---------|----------|-------------|
| [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally |
| [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally |
| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally | | [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) |
| OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) |
| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | Hermes Local | `hermes_local` | Runs Hermes CLI locally |
| Cursor | `cursor` | Runs Cursor in background mode |
| Pi Local | `pi_local` | Runs an embedded Pi agent locally |
| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint |
| [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands |
| [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents |
@ -55,7 +58,7 @@ Three registries consume these modules:
## Choosing an Adapter ## Choosing an Adapter
- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local` - **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_local`
- **Need to run a script or command?** Use `process` - **Need to run a script or command?** Use `process`
- **Need to call an external service?** Use `http` - **Need to call an external service?** Use `http`
- **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter)

View file

@ -46,6 +46,7 @@
"guides/board-operator/managing-agents", "guides/board-operator/managing-agents",
"guides/board-operator/org-structure", "guides/board-operator/org-structure",
"guides/board-operator/managing-tasks", "guides/board-operator/managing-tasks",
"guides/board-operator/delegation",
"guides/board-operator/approvals", "guides/board-operator/approvals",
"guides/board-operator/costs-and-budgets", "guides/board-operator/costs-and-budgets",
"guides/board-operator/activity-log", "guides/board-operator/activity-log",

View file

@ -0,0 +1,122 @@
---
title: How Delegation Works
summary: How the CEO breaks down goals into tasks and assigns them to agents
---
Delegation is one of Paperclip's most powerful features. You set company goals, and the CEO agent automatically breaks them into tasks and assigns them to the right agents. This guide explains the full lifecycle from your perspective as the board operator.
## The Delegation Lifecycle
When you create a company goal, the CEO doesn't just acknowledge it — it builds a plan and mobilizes the team:
```
You set a company goal
→ CEO wakes up on heartbeat
→ CEO proposes a strategy (creates an approval for you)
→ You approve the strategy
→ CEO breaks goals into tasks and assigns them to reports
→ Reports wake up (heartbeat triggered by assignment)
→ Reports execute work and update task status
→ CEO monitors progress, unblocks, and escalates
→ You see results in the dashboard and activity log
```
Each step is traceable. Every task links back to the goal through a parent hierarchy, so you can always see why work is happening.
## What You Need to Do
Your role is strategic oversight, not task management. Here's what the delegation model expects from you:
1. **Set clear company goals.** The CEO works from these. Specific, measurable goals produce better delegation. "Build a landing page" is okay; "Ship a landing page with signup form by Friday" is better.
2. **Approve the CEO's strategy.** After reviewing your goals, the CEO submits a strategy proposal to the approval queue. Review it, then approve, reject, or request revisions.
3. **Approve hire requests.** When the CEO needs more capacity (e.g., a frontend engineer to build the landing page), it submits a hire request. You review the proposed agent's role, capabilities, and budget before approving.
4. **Monitor progress.** Use the dashboard and activity log to track how work is flowing. Check task status, agent activity, and completion rates.
5. **Intervene only when things stall.** If progress stops, check these in order:
- Is an approval pending in your queue?
- Is an agent paused or in an error state?
- Is the CEO's budget exhausted (above 80%, it focuses on critical tasks only)?
## What the CEO Does Automatically
You do **not** need to tell the CEO to engage specific agents. After you approve its strategy, the CEO:
- **Breaks goals into concrete tasks** with clear descriptions, priorities, and acceptance criteria
- **Assigns tasks to the right agent** based on role and capabilities (e.g., engineering tasks go to the CTO or engineers, marketing tasks go to the CMO)
- **Creates subtasks** when work needs to be decomposed further
- **Hires new agents** when the team lacks capacity for a goal (subject to your approval)
- **Monitors progress** on each heartbeat, checking task status and unblocking reports
- **Escalates to you** when it encounters something it can't resolve — budget issues, blocked approvals, or strategic ambiguity
## Common Delegation Patterns
### Flat Hierarchy (Small Teams)
For small companies with 3-5 agents, the CEO delegates directly to each report:
```
CEO
├── CTO (engineering tasks)
├── CMO (marketing tasks)
└── Designer (design tasks)
```
The CEO assigns tasks directly. Each agent works independently and reports status back.
### Three-Level Hierarchy (Larger Teams)
For larger organizations, managers delegate further down the chain:
```
CEO
├── CTO
│ ├── Backend Engineer
│ └── Frontend Engineer
└── CMO
└── Content Writer
```
The CEO assigns high-level tasks to the CTO and CMO. They break those into subtasks and assign them to their own reports. You only interact with the CEO — the rest happens automatically.
### Hire-on-Demand
The CEO can start as the only agent and hire as work requires:
1. You set a goal that needs engineering work
2. The CEO proposes a strategy that includes hiring a CTO
3. You approve the hire
4. The CEO assigns engineering tasks to the new CTO
5. As scope grows, the CTO may request to hire engineers
This pattern lets you start small and scale the team based on actual work, not upfront planning.
## Troubleshooting
### "Why isn't the CEO delegating?"
If you've set a goal but nothing is happening, check these common causes:
| Check | What to look for |
|-------|-----------------|
| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. |
| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. |
| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. |
| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. |
| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. |
| **Agent instructions** | The CEO's delegation behavior is driven by its `AGENTS.md` instructions file. Open the CEO agent's detail page and verify that its instructions path is set and that the file includes delegation directives (subtask creation, hiring, assignment). If AGENTS.md is missing or doesn't mention delegation, the CEO won't know to break down goals and assign work. |
### "Do I have to tell the CEO to engage engineering and marketing?"
**No.** The CEO will delegate automatically after you approve its strategy. It knows the org chart and assigns tasks based on each agent's role and capabilities. You set the goal and approve the plan — the CEO handles task breakdown and assignment.
### "A task seems stuck"
If a specific task isn't progressing:
1. Check the task's comment thread — the assigned agent may have posted a blocker
2. Check if the task is in `blocked` status — read the blocker comment to understand why
3. Check the assigned agent's status — it may be paused or over budget
4. If the agent is stuck, you can reassign the task or add a comment with guidance

View file

@ -29,7 +29,7 @@ Create agents from the Agents page. Each agent requires:
Common adapter choices: Common adapter choices:
- `claude_local` / `codex_local` / `opencode_local` for local coding agents - `claude_local` / `codex_local` / `opencode_local` for local coding agents
- `openclaw` / `http` for webhook-based external agents - `openclaw_gateway` / `http` for webhook-based external agents
- `process` for generic local command execution - `process` for generic local command execution
For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`). For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`).

View file

@ -1,9 +1,9 @@
--- ---
title: Core Concepts title: Core Concepts
summary: Companies, agents, issues, heartbeats, and governance summary: Companies, agents, issues, delegation, heartbeats, and governance
--- ---
Paperclip organizes autonomous AI work around five key concepts. Paperclip organizes autonomous AI work around six key concepts.
## Company ## Company
@ -50,6 +50,17 @@ Terminal states: `done`, `cancelled`.
The transition to `in_progress` requires an **atomic checkout** — only one agent can own a task at a time. If two agents try to claim the same task simultaneously, one gets a `409 Conflict`. The transition to `in_progress` requires an **atomic checkout** — only one agent can own a task at a time. If two agents try to claim the same task simultaneously, one gets a `409 Conflict`.
## Delegation
The CEO is the primary delegator. When you set company goals, the CEO:
1. Creates a strategy and submits it for your approval
2. Breaks approved goals into tasks
3. Assigns tasks to agents based on their role and capabilities
4. Hires new agents when needed (subject to your approval)
You don't need to manually assign every task — set the goals and let the CEO organize the work. You approve key decisions (strategy, hiring) and monitor progress. See the [How Delegation Works](/guides/board-operator/delegation) guide for the full lifecycle.
## Heartbeats ## Heartbeats
Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip. Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip.

View file

@ -287,6 +287,12 @@ export interface ServerAdapterModule {
* without knowing provider-specific credential paths or API shapes. * without knowing provider-specific credential paths or API shapes.
*/ */
getQuotaWindows?: () => Promise<ProviderQuotaResult>; getQuotaWindows?: () => Promise<ProviderQuotaResult>;
/**
* Optional: detect the currently configured model from local config files.
* Returns the detected model/provider and the config source, or null if
* the adapter does not support detection or no config is found.
*/
detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [
] as const; ] as const;
export type IssueStatus = (typeof ISSUE_STATUSES)[number]; export type IssueStatus = (typeof ISSUE_STATUSES)[number];
export const INBOX_MINE_ISSUE_STATUSES = [
"backlog",
"todo",
"in_progress",
"in_review",
"blocked",
"done",
] as const;
export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(",");
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];

View file

@ -9,6 +9,8 @@ export {
AGENT_ROLE_LABELS, AGENT_ROLE_LABELS,
AGENT_ICON_NAMES, AGENT_ICON_NAMES,
ISSUE_STATUSES, ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
ISSUE_PRIORITIES, ISSUE_PRIORITIES,
ISSUE_ORIGIN_KINDS, ISSUE_ORIGIN_KINDS,
GOAL_LEVELS, GOAL_LEVELS,
@ -344,6 +346,7 @@ export {
upsertAgentInstructionsFileSchema, upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema, updateAgentInstructionsPathSchema,
createAgentKeySchema, createAgentKeySchema,
agentMineInboxQuerySchema,
wakeAgentSchema, wakeAgentSchema,
resetAgentSessionSchema, resetAgentSessionSchema,
testAdapterEnvironmentSchema, testAdapterEnvironmentSchema,
@ -356,6 +359,7 @@ export {
type UpsertAgentInstructionsFile, type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath, type UpdateAgentInstructionsPath,
type CreateAgentKey, type CreateAgentKey,
type AgentMineInboxQuery,
type WakeAgent, type WakeAgent,
type ResetAgentSession, type ResetAgentSession,
type TestAdapterEnvironment, type TestAdapterEnvironment,

View file

@ -4,6 +4,7 @@ import {
AGENT_ICON_NAMES, AGENT_ICON_NAMES,
AGENT_ROLES, AGENT_ROLES,
AGENT_STATUSES, AGENT_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
} from "../constants.js"; } from "../constants.js";
import { envConfigSchema } from "./secret.js"; import { envConfigSchema } from "./secret.js";
@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({
export type CreateAgentKey = z.infer<typeof createAgentKeySchema>; export type CreateAgentKey = z.infer<typeof createAgentKeySchema>;
export const agentMineInboxQuerySchema = z.object({
userId: z.string().trim().min(1),
status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER),
});
export type AgentMineInboxQuery = z.infer<typeof agentMineInboxQuerySchema>;
export const wakeAgentSchema = z.object({ export const wakeAgentSchema = z.object({
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),

View file

@ -85,6 +85,7 @@ export {
upsertAgentInstructionsFileSchema, upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema, updateAgentInstructionsPathSchema,
createAgentKeySchema, createAgentKeySchema,
agentMineInboxQuerySchema,
wakeAgentSchema, wakeAgentSchema,
resetAgentSessionSchema, resetAgentSessionSchema,
testAdapterEnvironmentSchema, testAdapterEnvironmentSchema,
@ -97,6 +98,7 @@ export {
type UpsertAgentInstructionsFile, type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath, type UpdateAgentInstructionsPath,
type CreateAgentKey, type CreateAgentKey,
type AgentMineInboxQuery,
type WakeAgent, type WakeAgent,
type ResetAgentSession, type ResetAgentSession,
type TestAdapterEnvironment, type TestAdapterEnvironment,

View file

@ -66,6 +66,7 @@ export type CreateIssueLabel = z.infer<typeof createIssueLabelSchema>;
export const updateIssueSchema = createIssueSchema.partial().extend({ export const updateIssueSchema = createIssueSchema.partial().extend({
comment: z.string().min(1).optional(), comment: z.string().min(1).optional(),
reopen: z.boolean().optional(), reopen: z.boolean().optional(),
interrupt: z.boolean().optional(),
hiddenAt: z.string().datetime().nullable().optional(), hiddenAt: z.string().datetime().nullable().optional(),
}); });

21
pnpm-lock.yaml generated
View file

@ -504,8 +504,8 @@ importers:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.2.1 version: 5.2.1
hermes-paperclip-adapter: hermes-paperclip-adapter:
specifier: 0.1.1 specifier: ^0.2.0
version: 0.1.1 version: 0.2.0
jsdom: jsdom:
specifier: ^28.1.0 specifier: ^28.1.0
version: 28.1.0(@noble/hashes@2.0.1) version: 28.1.0(@noble/hashes@2.0.1)
@ -639,6 +639,9 @@ importers:
cmdk: cmdk:
specifier: ^1.1.1 specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
hermes-paperclip-adapter:
specifier: ^0.2.0
version: 0.2.0
lexical: lexical:
specifier: 0.35.0 specifier: 0.35.0
version: 0.35.0 version: 0.35.0
@ -2040,8 +2043,8 @@ packages:
'@open-draft/deferred-promise@2.2.0': '@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@paperclipai/adapter-utils@0.3.1': '@paperclipai/adapter-utils@2026.325.0':
resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==} resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==}
'@paralleldrive/cuid2@2.3.1': '@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@ -4468,8 +4471,8 @@ packages:
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
hermes-paperclip-adapter@0.1.1: hermes-paperclip-adapter@0.2.0:
resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==} resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
html-encoding-sniffer@6.0.0: html-encoding-sniffer@6.0.0:
@ -7740,7 +7743,7 @@ snapshots:
'@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@2.2.0': {}
'@paperclipai/adapter-utils@0.3.1': {} '@paperclipai/adapter-utils@2026.325.0': {}
'@paralleldrive/cuid2@2.3.1': '@paralleldrive/cuid2@2.3.1':
dependencies: dependencies:
@ -10337,9 +10340,9 @@ snapshots:
help-me@5.0.0: {} help-me@5.0.0: {}
hermes-paperclip-adapter@0.1.1: hermes-paperclip-adapter@0.2.0:
dependencies: dependencies:
'@paperclipai/adapter-utils': 0.3.1 '@paperclipai/adapter-utils': 2026.325.0
picocolors: 1.1.1 picocolors: 1.1.1
html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):

View file

@ -65,7 +65,7 @@
"drizzle-orm": "^0.38.4", "drizzle-orm": "^0.38.4",
"embedded-postgres": "^18.1.0-beta.16", "embedded-postgres": "^18.1.0-beta.16",
"express": "^5.1.0", "express": "^5.1.0",
"hermes-paperclip-adapter": "0.1.1", "hermes-paperclip-adapter": "^0.2.0",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"open": "^11.0.0", "open": "^11.0.0",

View file

@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts"; import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const tsxCliPath = require.resolve("tsx/dist/cli.mjs"); const tsxCliPath = require.resolve("tsx/cli");
const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]); const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]);

View file

@ -1,6 +1,7 @@
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { agentRoutes } from "../routes/agents.js"; import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js"; import { errorHandler } from "../middleware/index.js";
@ -272,4 +273,42 @@ describe("agent permission routes", () => {
expect(res.body.access.canAssignTasks).toBe(true); expect(res.body.access.canAssignTasks).toBe(true);
expect(res.body.access.taskAssignSource).toBe("agent_creator"); expect(res.body.access.taskAssignSource).toBe("agent_creator");
}); });
it("exposes a dedicated agent route for the inbox mine view", async () => {
mockIssueService.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-910",
title: "Inbox follow-up",
status: "todo",
},
]);
const app = createApp({
type: "agent",
agentId,
companyId,
runId: "run-1",
source: "agent_key",
});
const res = await request(app)
.get("/api/agents/me/inbox/mine")
.query({ userId: "board-user" });
expect(res.status).toBe(200);
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
touchedByUserId: "board-user",
inboxArchivedByUserId: "board-user",
status: INBOX_MINE_ISSUE_STATUS_FILTER,
});
expect(res.body).toEqual([
{
id: "issue-1",
identifier: "PAP-910",
title: "Inbox follow-up",
status: "todo",
},
]);
});
}); });

View file

@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({
const mockHeartbeatService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined), wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
})); }));
const mockAgentService = vi.hoisted(() => ({ const mockAgentService = vi.hoisted(() => ({
@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => {
}), }),
); );
}); });
it("interrupts an active run before a combined comment update", async () => {
const issue = {
...makeIssue("todo"),
executionRunId: "run-1",
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
}));
mockHeartbeatService.getRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "running",
});
mockHeartbeatService.cancelRun.mockResolvedValue({
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
status: "cancelled",
});
const res = await request(createApp())
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" });
expect(res.status).toBe(200);
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1");
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1");
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "heartbeat.cancelled",
details: expect.objectContaining({
source: "issue_comment_interrupt",
issueId: "11111111-1111-4111-8111-111111111111",
}),
}),
);
});
}); });

View file

@ -1,4 +1,4 @@
export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js"; export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js";
export type { export type {
ServerAdapterModule, ServerAdapterModule,
AdapterExecutionContext, AdapterExecutionContext,

View file

@ -70,6 +70,9 @@ import {
execute as hermesExecute, execute as hermesExecute,
testEnvironment as hermesTestEnvironment, testEnvironment as hermesTestEnvironment,
sessionCodec as hermesSessionCodec, sessionCodec as hermesSessionCodec,
listSkills as hermesListSkills,
syncSkills as hermesSyncSkills,
detectModel as detectModelFromHermes,
} from "hermes-paperclip-adapter/server"; } from "hermes-paperclip-adapter/server";
import { import {
agentConfigurationDoc as hermesAgentConfigurationDoc, agentConfigurationDoc as hermesAgentConfigurationDoc,
@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = {
execute: hermesExecute, execute: hermesExecute,
testEnvironment: hermesTestEnvironment, testEnvironment: hermesTestEnvironment,
sessionCodec: hermesSessionCodec, sessionCodec: hermesSessionCodec,
listSkills: hermesListSkills,
syncSkills: hermesSyncSkills,
models: hermesModels, models: hermesModels,
supportsLocalAgentJwt: true, supportsLocalAgentJwt: true,
agentConfigurationDoc: hermesAgentConfigurationDoc, agentConfigurationDoc: hermesAgentConfigurationDoc,
detectModel: () => detectModelFromHermes(),
}; };
const adaptersByType = new Map<string, ServerAdapterModule>( const adaptersByType = new Map<string, ServerAdapterModule>(
@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
return Array.from(adaptersByType.values()); return Array.from(adaptersByType.values());
} }
export async function detectAdapterModel(
type: string,
): Promise<{ model: string; provider: string; source: string } | null> {
const adapter = adaptersByType.get(type);
if (!adapter?.detectModel) return null;
const detected = await adapter.detectModel();
return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null;
}
export function findServerAdapter(type: string): ServerAdapterModule | null { export function findServerAdapter(type: string): ServerAdapterModule | null {
return adaptersByType.get(type) ?? null; return adaptersByType.get(type) ?? null;
} }

View file

@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db
import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import { import {
agentSkillSyncSchema, agentSkillSyncSchema,
agentMineInboxQuerySchema,
createAgentKeySchema, createAgentKeySchema,
createAgentHireSchema, createAgentHireSchema,
createAgentSchema, createAgentSchema,
@ -44,7 +45,7 @@ import {
} from "../services/index.js"; } from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js"; import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
@ -671,6 +672,15 @@ export function agentRoutes(db: Db) {
res.json(models); res.json(models);
}); });
router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const type = req.params.type as string;
const detected = await detectAdapterModel(type);
res.json(detected);
});
router.post( router.post(
"/companies/:companyId/adapters/:type/test-environment", "/companies/:companyId/adapters/:type/test-environment",
validate(testAdapterEnvironmentSchema), validate(testAdapterEnvironmentSchema),
@ -997,6 +1007,23 @@ export function agentRoutes(db: Db) {
); );
}); });
router.get("/agents/me/inbox/mine", async (req, res) => {
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
res.status(401).json({ error: "Agent authentication required" });
return;
}
const query = agentMineInboxQuerySchema.parse(req.query);
const issuesSvc = issueService(db);
const rows = await issuesSvc.list(req.actor.companyId, {
touchedByUserId: query.userId,
inboxArchivedByUserId: query.userId,
status: query.status,
});
res.json(rows);
});
router.get("/agents/:id", async (req, res) => { router.get("/agents/:id", async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const agent = await svc.getById(id); const agent = await svc.getById(id);

View file

@ -1,5 +1,6 @@
import { Router, type Request, type Response } from "express"; import { Router, type Request, type Response } from "express";
import multer from "multer"; import multer from "multer";
import { z } from "zod";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
addIssueCommentSchema, addIssueCommentSchema,
@ -38,6 +39,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
const MAX_ISSUE_COMMENT_LIMIT = 500; const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
export function issueRoutes(db: Db, storage: StorageService) { export function issueRoutes(db: Db, storage: StorageService) {
const router = Router(); const router = Router();
@ -161,6 +165,30 @@ export function issueRoutes(db: Db, storage: StorageService) {
return true; return true;
} }
async function resolveActiveIssueRun(issue: {
id: string;
assigneeAgentId: string | null;
executionRunId?: string | null;
}) {
let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) {
const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId);
const activeIssueId =
activeRun &&
activeRun.contextSnapshot &&
typeof activeRun.contextSnapshot === "object" &&
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
: null;
if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) {
runToInterrupt = activeRun;
}
}
return runToInterrupt?.status === "running" ? runToInterrupt : null;
}
async function normalizeIssueIdentifier(rawId: string): Promise<string> { async function normalizeIssueIdentifier(rawId: string): Promise<string> {
if (/^[A-Z]+-\d+$/i.test(rawId)) { if (/^[A-Z]+-\d+$/i.test(rawId)) {
const issue = await svc.getByIdentifier(rawId); const issue = await svc.getByIdentifier(rawId);
@ -713,6 +741,38 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.json(readState); res.json(readState);
}); });
router.delete("/issues/:id/read", async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.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 removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.read_unmarked",
entityType: "issue",
entityId: issue.id,
details: { userId: req.actor.userId },
});
res.json({ id: issue.id, removed });
});
router.post("/issues/:id/inbox-archive", async (req, res) => { router.post("/issues/:id/inbox-archive", async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const issue = await svc.getById(id); const issue = await svc.getById(id);
@ -887,7 +947,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
res.status(201).json(issue); res.status(201).json(issue);
}); });
router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => { router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const existing = await svc.getById(id); const existing = await svc.getById(id);
if (!existing) { if (!existing) {
@ -917,7 +977,45 @@ export function issueRoutes(db: Db, storage: StorageService) {
const actor = getActorInfo(req); const actor = getActorInfo(req);
const isClosed = existing.status === "done" || existing.status === "cancelled"; const isClosed = existing.status === "done" || existing.status === "cancelled";
const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; const {
comment: commentBody,
reopen: reopenRequested,
interrupt: interruptRequested,
hiddenAt: hiddenAtRaw,
...updateFields
} = req.body;
let interruptedRunId: string | null = null;
if (interruptRequested) {
if (!commentBody) {
res.status(400).json({ error: "Interrupt is only supported when posting a comment" });
return;
}
if (req.actor.type !== "board") {
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
return;
}
const runToInterrupt = await resolveActiveIssueRun(existing);
if (runToInterrupt) {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
if (cancelled) {
interruptedRunId = cancelled.id;
await logActivity(db, {
companyId: cancelled.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "heartbeat.cancelled",
entityType: "heartbeat_run",
entityId: cancelled.id,
details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id },
});
}
}
}
if (hiddenAtRaw !== undefined) { if (hiddenAtRaw !== undefined) {
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
} }
@ -992,6 +1090,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
identifier: issue.identifier, identifier: issue.identifier,
...(commentBody ? { source: "comment" } : {}), ...(commentBody ? { source: "comment" } : {}),
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
_previous: hasFieldChanges ? previous : undefined, _previous: hasFieldChanges ? previous : undefined,
}, },
}); });
@ -1018,6 +1117,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
identifier: issue.identifier, identifier: issue.identifier,
issueTitle: issue.title, issueTitle: issue.title,
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(hasFieldChanges ? { updated: true } : {}), ...(hasFieldChanges ? { updated: true } : {}),
}, },
}); });
@ -1039,10 +1139,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
source: "assignment", source: "assignment",
triggerDetail: "system", triggerDetail: "system",
reason: "issue_assigned", reason: "issue_assigned",
payload: { issueId: issue.id, mutation: "update" }, payload: {
issueId: issue.id,
mutation: "update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType, requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId, requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.update" }, contextSnapshot: {
issueId: issue.id,
source: "issue.update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
}); });
} }
@ -1051,10 +1159,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
source: "automation", source: "automation",
triggerDetail: "system", triggerDetail: "system",
reason: "issue_status_changed", reason: "issue_status_changed",
payload: { issueId: issue.id, mutation: "update" }, payload: {
issueId: issue.id,
mutation: "update",
...(interruptedRunId ? { interruptedRunId } : {}),
},
requestedByActorType: actor.actorType, requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId, requestedByActorId: actor.actorId,
contextSnapshot: { issueId: issue.id, source: "issue.status_change" }, contextSnapshot: {
issueId: issue.id,
source: "issue.status_change",
...(interruptedRunId ? { interruptedRunId } : {}),
},
}); });
} }
@ -1347,28 +1463,8 @@ export function issueRoutes(db: Db, storage: StorageService) {
return; return;
} }
let runToInterrupt = currentIssue.executionRunId const runToInterrupt = await resolveActiveIssueRun(currentIssue);
? await heartbeat.getRun(currentIssue.executionRunId) if (runToInterrupt) {
: null;
if (
(!runToInterrupt || runToInterrupt.status !== "running") &&
currentIssue.assigneeAgentId
) {
const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId);
const activeIssueId =
activeRun &&
activeRun.contextSnapshot &&
typeof activeRun.contextSnapshot === "object" &&
typeof (activeRun.contextSnapshot as Record<string, unknown>).issueId === "string"
? ((activeRun.contextSnapshot as Record<string, unknown>).issueId as string)
: null;
if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) {
runToInterrupt = activeRun;
}
}
if (runToInterrupt && runToInterrupt.status === "running") {
const cancelled = await heartbeat.cancelRun(runToInterrupt.id); const cancelled = await heartbeat.cancelRun(runToInterrupt.id);
if (cancelled) { if (cancelled) {
interruptedRunId = cancelled.id; interruptedRunId = cancelled.id;

View file

@ -791,6 +791,20 @@ export function issueService(db: Db) {
return row; return row;
}, },
markUnread: async (companyId: string, issueId: string, userId: string) => {
const deleted = await db
.delete(issueReadStates)
.where(
and(
eq(issueReadStates.companyId, companyId),
eq(issueReadStates.issueId, issueId),
eq(issueReadStates.userId, userId),
),
)
.returning();
return deleted.length > 0;
},
archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => { archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => {
const now = new Date(); const now = new Date();
const [row] = await db const [row] = await db

View file

@ -255,6 +255,7 @@ PATCH /api/agents/{agentId}/instructions-path
| ----------------------------------------- | ------------------------------------------------------------------------------------------ | | ----------------------------------------- | ------------------------------------------------------------------------------------------ |
| My identity | `GET /api/agents/me` | | My identity | `GET /api/agents/me` |
| My compact inbox | `GET /api/agents/me/inbox-lite` | | My compact inbox | `GET /api/agents/me/inbox-lite` |
| Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` |
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | | My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
| Checkout task | `POST /api/issues/:issueId/checkout` | | Checkout task | `POST /api/issues/:issueId/checkout` |
| Get task + ancestors | `GET /api/issues/:issueId` | | Get task + ancestors | `GET /api/issues/:issueId` |

View file

@ -226,6 +226,34 @@ PATCH /api/issues/issue-99
{ "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." } { "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." }
``` ```
### Worked Example: Report A Board User's Mine Inbox
When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses.
```
# Board user created the requesting issue.
GET /api/issues/issue-200
-> { id: "issue-200", createdByUserId: "user-7", ... }
# Fetch the board user's Mine inbox issues.
GET /api/agents/me/inbox/mine?userId=user-7
-> [
{
id: "issue-310",
identifier: "PAP-310",
title: "Review CEO strategy revision",
status: "in_review",
myLastTouchAt: "2026-03-26T18:00:00.000Z",
lastExternalCommentAt: "2026-03-26T19:10:00.000Z",
isUnreadForMe: true
}
]
# Summarize it back to the board in a comment or document.
PATCH /api/issues/issue-200
{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." }
```
--- ---
## Worked Example: Manager Heartbeat ## Worked Example: Manager Heartbeat
@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled`
| Method | Path | Description | | Method | Path | Description |
| ------ | ---------------------------------- | ------------------------------------ | | ------ | ---------------------------------- | ------------------------------------ |
| GET | `/api/agents/me` | Your agent record + chain of command | | GET | `/api/agents/me` | Your agent record + chain of command |
| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user |
| GET | `/api/agents/:agentId` | Agent details + chain of command | | GET | `/api/agents/:agentId` | Agent details + chain of command |
| GET | `/api/companies/:companyId/agents` | List all agents in company | | GET | `/api/companies/:companyId/agents` | List all agents in company |
| GET | `/api/companies/:companyId/org` | Org chart tree | | GET | `/api/companies/:companyId/org` | Org chart tree |

View file

@ -41,6 +41,7 @@
"@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*", "@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*", "@paperclipai/shared": "workspace:*",
"hermes-paperclip-adapter": "^0.2.0",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",

View file

@ -0,0 +1,49 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function HermesLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}

View file

@ -0,0 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
import { HermesLocalConfigFields } from "./config-fields";
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
export const hermesLocalUIAdapter: UIAdapterModule = {
type: "hermes_local",
label: "Hermes Agent",
parseStdoutLine: parseHermesStdoutLine,
ConfigFields: HermesLocalConfigFields,
buildAdapterConfig: buildHermesConfig,
};

View file

@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local"; import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor"; import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local"; import { geminiLocalUIAdapter } from "./gemini-local";
import { hermesLocalUIAdapter } from "./hermes-local";
import { openCodeLocalUIAdapter } from "./opencode-local"; import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local"; import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { openClawGatewayUIAdapter } from "./openclaw-gateway";
@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [
claudeLocalUIAdapter, claudeLocalUIAdapter,
codexLocalUIAdapter, codexLocalUIAdapter,
geminiLocalUIAdapter, geminiLocalUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter, openCodeLocalUIAdapter,
piLocalUIAdapter, piLocalUIAdapter,
cursorLocalUIAdapter, cursorLocalUIAdapter,

View file

@ -27,6 +27,12 @@ export interface AdapterModel {
label: string; label: string;
} }
export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
}
export interface ClaudeLoginResult { export interface ClaudeLoginResult {
exitCode: number | null; exitCode: number | null;
signal: string | null; signal: string | null;
@ -159,6 +165,10 @@ export const agentsApi = {
api.get<AdapterModel[]>( api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
), ),
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel | null>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
),
testEnvironment: ( testEnvironment: (
companyId: string, companyId: string,
type: string, type: string,

View file

@ -11,6 +11,10 @@ import type {
} from "@paperclipai/shared"; } from "@paperclipai/shared";
import { api } from "./client"; import { api } from "./client";
export type IssueUpdateResponse = Issue & {
comment?: IssueComment | null;
};
export const issuesApi = { export const issuesApi = {
list: ( list: (
companyId: string, companyId: string,
@ -53,13 +57,15 @@ export const issuesApi = {
deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`), deleteLabel: (id: string) => api.delete<IssueLabel>(`/labels/${id}`),
get: (id: string) => api.get<Issue>(`/issues/${id}`), get: (id: string) => api.get<Issue>(`/issues/${id}`),
markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}), markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}),
markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`),
archiveFromInbox: (id: string) => archiveFromInbox: (id: string) =>
api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}), api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}),
unarchiveFromInbox: (id: string) => unarchiveFromInbox: (id: string) =>
api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`), api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`),
create: (companyId: string, data: Record<string, unknown>) => create: (companyId: string, data: Record<string, unknown>) =>
api.post<Issue>(`/companies/${companyId}/issues`, data), api.post<Issue>(`/companies/${companyId}/issues`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<Issue>(`/issues/${id}`, data), update: (id: string, data: Record<string, unknown>) =>
api.patch<IssueUpdateResponse>(`/issues/${id}`, data),
remove: (id: string) => api.delete<Issue>(`/issues/${id}`), remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
checkout: (id: string, agentId: string) => checkout: (id: string, agentId: string) =>
api.post<Issue>(`/issues/${id}/checkout`, { api.post<Issue>(`/issues/${id}/checkout`, {

View file

@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
} }
if (overlay.adapterType !== undefined) { if (overlay.adapterType !== undefined) {
patch.adapterType = overlay.adapterType; patch.adapterType = overlay.adapterType;
// When adapter type changes, send only the new config — don't merge // When adapter type changes, replace adapter-specific fields but preserve
// with old config since old adapter fields are meaningless for the new type // adapter-agnostic fields (env, promptTemplate, etc.) that are shared
patch.adapterConfig = overlay.adapterConfig; // across all adapter types.
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
const adapterAgnosticKeys = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
];
const preserved: Record<string, unknown> = {};
for (const key of adapterAgnosticKeys) {
if (key in existing) {
preserved[key] = existing[key];
}
}
patch.adapterConfig = { ...preserved, ...overlay.adapterConfig };
} else if (Object.keys(overlay.adapterConfig).length > 0) { } else if (Object.keys(overlay.adapterConfig).length > 0) {
const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>; const existing = (agent.adapterConfig ?? {}) as Record<string, unknown>;
patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; patch.adapterConfig = { ...existing, ...overlay.adapterConfig };
@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
adapterType === "claude_local" || adapterType === "claude_local" ||
adapterType === "codex_local" || adapterType === "codex_local" ||
adapterType === "gemini_local" || adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" || adapterType === "opencode_local" ||
adapterType === "pi_local" || adapterType === "pi_local" ||
adapterType === "cursor"; adapterType === "cursor";
const isHermesLocal = adapterType === "hermes_local";
const showLegacyWorkingDirectoryField = const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
enabled: Boolean(selectedCompanyId), enabled: Boolean(selectedCompanyId),
}); });
const models = fetchedModels ?? externalModels ?? []; const models = fetchedModels ?? externalModels ?? [];
const {
data: detectedModelData,
refetch: refetchDetectedModel,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
: ["agents", "none", "detect-model", adapterType],
queryFn: () => {
if (!selectedCompanyId) {
throw new Error("Select a company to detect the Hermes model");
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
enabled: Boolean(selectedCompanyId && isHermesLocal),
});
const detectedModel = detectedModelData?.model ?? null;
const { data: companyAgents = [] } = useQuery({ const { data: companyAgents = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
? "codex" ? "codex"
: adapterType === "gemini_local" : adapterType === "gemini_local"
? "gemini" ? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local" : adapterType === "pi_local"
? "pi" ? "pi"
: adapterType === "cursor" : adapterType === "cursor"
@ -709,9 +746,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
} }
open={modelOpen} open={modelOpen}
onOpenChange={setModelOpen} onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"} allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
required={adapterType === "opencode_local"} required={adapterType === "opencode_local" || adapterType === "hermes_local"}
groupByProvider={adapterType === "opencode_local"} groupByProvider={adapterType === "opencode_local"}
creatable={adapterType === "hermes_local"}
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
onDetectModel={adapterType === "hermes_local"
? async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}
: undefined}
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
/> />
{fetchedModelsError && ( {fetchedModelsError && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
@ -976,7 +1022,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */ /* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */ /** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
@ -1293,6 +1339,10 @@ function ModelDropdown({
allowDefault, allowDefault,
required, required,
groupByProvider, groupByProvider,
creatable,
detectedModel,
onDetectModel,
detectModelLabel,
}: { }: {
models: AdapterModel[]; models: AdapterModel[];
value: string; value: string;
@ -1302,9 +1352,20 @@ function ModelDropdown({
allowDefault: boolean; allowDefault: boolean;
required: boolean; required: boolean;
groupByProvider: boolean; groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
onDetectModel?: () => Promise<string | null>;
detectModelLabel?: string;
}) { }) {
const [modelSearch, setModelSearch] = useState(""); const [modelSearch, setModelSearch] = useState("");
const [detectingModel, setDetectingModel] = useState(false);
const selected = models.find((m) => m.id === value); const selected = models.find((m) => m.id === value);
const manualModel = modelSearch.trim();
const canCreateManualModel = Boolean(
creatable &&
manualModel &&
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
);
const filteredModels = useMemo(() => { const filteredModels = useMemo(() => {
return models.filter((m) => { return models.filter((m) => {
if (!modelSearch.trim()) return true; if (!modelSearch.trim()) return true;
@ -1341,6 +1402,21 @@ function ModelDropdown({
})); }));
}, [filteredModels, groupByProvider]); }, [filteredModels, groupByProvider]);
async function handleDetectModel() {
if (!onDetectModel) return;
setDetectingModel(true);
try {
const nextModel = await onDetectModel();
if (nextModel) {
onChange(nextModel);
onOpenChange(false);
setModelSearch("");
}
} finally {
setDetectingModel(false);
}
}
return ( return (
<Field label="Model" hint={help.model}> <Field label="Model" hint={help.model}>
<Popover <Popover
@ -1351,7 +1427,7 @@ function ModelDropdown({
}} }}
> >
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between"> <button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}> <span className={cn(!value && "text-muted-foreground")}>
{selected {selected
? selected.label ? selected.label
@ -1361,16 +1437,84 @@ function ModelDropdown({
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start"> <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<input <div className="relative mb-1">
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50" <input
placeholder="Search models..." className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
value={modelSearch} placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
onChange={(e) => setModelSearch(e.target.value)} value={modelSearch}
autoFocus onChange={(e) => setModelSearch(e.target.value)}
/> autoFocus
/>
{modelSearch && (
<button
type="button"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setModelSearch("")}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{onDetectModel && !detectedModel && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
void handleDetectModel();
}}
disabled={detectingModel}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
</button>
)}
{value && !models.some((m) => m.id === value) && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
onClick={() => {
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
{value}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
current
</span>
</button>
)}
{detectedModel && detectedModel !== value && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(detectedModel);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
{detectedModel}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
detected
</span>
</button>
)}
<div className="max-h-[240px] overflow-y-auto"> <div className="max-h-[240px] overflow-y-auto">
{allowDefault && ( {allowDefault && (
<button <button
type="button"
className={cn( className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50", "flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent", !value && "bg-accent",
@ -1383,6 +1527,20 @@ function ModelDropdown({
Default Default
</button> </button>
)} )}
{canCreateManualModel && (
<button
type="button"
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
onOpenChange(false);
setModelSearch("");
}}
>
<span>Use manual model</span>
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
</button>
)}
{groupedModels.map((group) => ( {groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0"> <div key={group.provider} className="mb-1 last:mb-0">
{groupByProvider && ( {groupByProvider && (
@ -1392,6 +1550,7 @@ function ModelDropdown({
)} )}
{group.entries.map((m) => ( {group.entries.map((m) => (
<button <button
type="button"
key={m.id} key={m.id}
className={cn( className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50", "flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
@ -1409,8 +1568,14 @@ function ModelDropdown({
))} ))}
</div> </div>
))} ))}
{filteredModels.length === 0 && ( {filteredModels.length === 0 && !canCreateManualModel && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p> <div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
: "No models found."}
</p>
</div>
)} )}
</div> </div>
</PopoverContent> </PopoverContent>

View file

@ -15,6 +15,10 @@ import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment { interface CommentWithRunMeta extends IssueComment {
runId?: string | null; runId?: string | null;
runAgentId?: string | null; runAgentId?: string | null;
clientId?: string;
clientStatus?: "pending" | "queued";
queueState?: "queued";
queueTargetRunId?: string | null;
} }
interface LinkedRunItem { interface LinkedRunItem {
@ -32,6 +36,7 @@ interface CommentReassignment {
interface CommentThreadProps { interface CommentThreadProps {
comments: CommentWithRunMeta[]; comments: CommentWithRunMeta[];
queuedComments?: CommentWithRunMeta[];
linkedRuns?: LinkedRunItem[]; linkedRuns?: LinkedRunItem[];
companyId?: string | null; companyId?: string | null;
projectId?: string | null; projectId?: string | null;
@ -48,6 +53,8 @@ interface CommentThreadProps {
currentAssigneeValue?: string; currentAssigneeValue?: string;
suggestedAssigneeValue?: string; suggestedAssigneeValue?: string;
mentions?: MentionOption[]; mentions?: MentionOption[];
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
} }
const DRAFT_DEBOUNCE_MS = 800; const DRAFT_DEBOUNCE_MS = 800;
@ -114,6 +121,122 @@ function CopyMarkdownButton({ text }: { text: string }) {
); );
} }
function CommentCard({
comment,
agentMap,
companyId,
projectId,
highlightCommentId,
queued = false,
}: {
comment: CommentWithRunMeta;
agentMap?: Map<string, Agent>;
companyId?: string | null;
projectId?: string | null;
highlightCommentId?: string | null;
queued?: boolean;
}) {
const isHighlighted = highlightCommentId === comment.id;
const isPending = comment.clientStatus === "pending";
const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued";
return (
<div
key={comment.id}
id={`comment-${comment.id}`}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${
isQueued
? "border-amber-300/70 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/10"
: isHighlighted
? "border-primary/50 bg-primary/5"
: "border-border"
} ${isPending ? "opacity-80" : ""}`}
>
<div className="flex items-center justify-between mb-1">
{comment.authorAgentId ? (
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{isQueued ? (
<span className="inline-flex items-center rounded-full border border-amber-400/60 bg-amber-100/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-amber-800 dark:border-amber-400/40 dark:bg-amber-500/20 dark:text-amber-200">
Queued
</span>
) : null}
{companyId && !isPending ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
{isPending ? (
<span className="text-xs text-muted-foreground">{isQueued ? "Queueing..." : "Sending..."}</span>
) : (
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
)}
<CopyMarkdownButton text={comment.body} />
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{companyId && !isPending ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.runId && !isPending ? (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)}
</div>
) : null}
</div>
);
}
type TimelineItem = type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
@ -168,86 +291,15 @@ const TimelineList = memo(function TimelineList({
} }
const comment = item.comment; const comment = item.comment;
const isHighlighted = highlightCommentId === comment.id;
return ( return (
<div <CommentCard
key={comment.id} key={comment.id}
id={`comment-${comment.id}`} comment={comment}
className={`border p-3 overflow-hidden min-w-0 rounded-sm transition-colors duration-1000 ${isHighlighted ? "border-primary/50 bg-primary/5" : "border-border"}`} agentMap={agentMap}
> companyId={companyId}
<div className="flex items-center justify-between mb-1"> projectId={projectId}
{comment.authorAgentId ? ( highlightCommentId={highlightCommentId}
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline"> />
<Identity
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
size="sm"
/>
</Link>
) : (
<Identity name="You" size="sm" />
)}
<span className="flex items-center gap-1.5">
{companyId ? (
<PluginSlotOutlet
slotTypes={["commentContextMenuItem"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="flex flex-wrap items-center gap-1.5"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
) : null}
<a
href={`#comment-${comment.id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
>
{formatDateTime(comment.createdAt)}
</a>
<CopyMarkdownButton text={comment.body} />
</span>
</div>
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
{companyId ? (
<div className="mt-2 space-y-2">
<PluginSlotOutlet
slotTypes={["commentAnnotation"]}
entityType="comment"
context={{
companyId,
projectId: projectId ?? null,
entityId: comment.id,
entityType: "comment",
parentEntityId: comment.issueId,
}}
className="space-y-2"
itemClassName="rounded-md"
missingBehavior="placeholder"
/>
</div>
) : null}
{comment.runId && (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)}
</div>
)}
</div>
); );
})} })}
</div> </div>
@ -256,6 +308,7 @@ const TimelineList = memo(function TimelineList({
export function CommentThread({ export function CommentThread({
comments, comments,
queuedComments = [],
linkedRuns = [], linkedRuns = [],
companyId, companyId,
projectId, projectId,
@ -270,6 +323,8 @@ export function CommentThread({
currentAssigneeValue = "", currentAssigneeValue = "",
suggestedAssigneeValue, suggestedAssigneeValue,
mentions: providedMentions, mentions: providedMentions,
onInterruptQueued,
interruptingQueuedRunId = null,
}: CommentThreadProps) { }: CommentThreadProps) {
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true); const [reopen, setReopen] = useState(true);
@ -345,7 +400,7 @@ export function CommentThread({
// Scroll to comment when URL hash matches #comment-{id} // Scroll to comment when URL hash matches #comment-{id}
useEffect(() => { useEffect(() => {
const hash = location.hash; const hash = location.hash;
if (!hash.startsWith("#comment-") || comments.length === 0) return; if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return;
const commentId = hash.slice("#comment-".length); const commentId = hash.slice("#comment-".length);
// Only scroll once per hash // Only scroll once per hash
if (hasScrolledRef.current) return; if (hasScrolledRef.current) return;
@ -358,7 +413,7 @@ export function CommentThread({
const timer = setTimeout(() => setHighlightCommentId(null), 3000); const timer = setTimeout(() => setHighlightCommentId(null), 3000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [location.hash, comments]); }, [location.hash, comments, queuedComments]);
async function handleSubmit() { async function handleSubmit() {
const trimmed = body.trim(); const trimmed = body.trim();
@ -373,6 +428,8 @@ export function CommentThread({
if (draftKey) clearDraft(draftKey); if (draftKey) clearDraft(draftKey);
setReopen(true); setReopen(true);
setReassignTarget(effectiveSuggestedAssigneeValue); setReassignTarget(effectiveSuggestedAssigneeValue);
} catch {
// Parent mutation handlers surface the failure and keep the draft intact.
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -401,18 +458,54 @@ export function CommentThread({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length})</h3> <h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
<TimelineList {timeline.length > 0 ? (
timeline={timeline} <TimelineList
agentMap={agentMap} timeline={timeline}
companyId={companyId} agentMap={agentMap}
projectId={projectId} companyId={companyId}
highlightCommentId={highlightCommentId} projectId={projectId}
/> highlightCommentId={highlightCommentId}
/>
) : null}
{liveRunSlot} {liveRunSlot}
{queuedComments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700 dark:text-amber-300">
Queued Comments ({queuedComments.length})
</h4>
{onInterruptQueued && queuedComments[0]?.queueTargetRunId ? (
<Button
size="sm"
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
disabled={interruptingQueuedRunId === queuedComments[0].queueTargetRunId}
onClick={() => void onInterruptQueued(queuedComments[0]!.queueTargetRunId!)}
>
{interruptingQueuedRunId === queuedComments[0].queueTargetRunId ? "Interrupting..." : "Interrupt"}
</Button>
) : null}
</div>
<div className="space-y-3">
{queuedComments.map((comment) => (
<CommentCard
key={comment.id}
comment={comment}
agentMap={agentMap}
companyId={companyId}
projectId={projectId}
highlightCommentId={highlightCommentId}
queued
/>
))}
</div>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<MarkdownEditor <MarkdownEditor
ref={editorRef} ref={editorRef}

View file

@ -4,7 +4,7 @@ import { useQueries } from "@tanstack/react-query";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
PointerSensor, MouseSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
@ -244,7 +244,8 @@ export function CompanyRail() {
// Require 8px of movement before starting a drag to avoid interfering with clicks // Require 8px of movement before starting a drag to avoid interfering with clicks
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { // Keep sidebar reordering mouse-only so touch input can scroll/tap without drag affordances.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
}) })
); );

View file

@ -0,0 +1,43 @@
import { cn } from "../lib/utils";
interface HermesIconProps {
className?: string;
}
/**
* Hermes caduceus icon winged staff with two intertwined serpents.
* Replaces the generic Zap icon for the hermes_local adapter type.
*
* inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings.
*/
export function HermesIcon({ className }: HermesIconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
{/* Central staff */}
<line x1="12" y1="6" x2="12" y2="23" />
{/* Left serpent curves */}
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
{/* Right serpent curves */}
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
{/* Snake heads facing outward */}
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
{/* Wings at top of staff */}
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
{/* Wing feather details */}
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
{/* Staff sphere at top */}
<circle cx="12" cy="6.5" r="1.2" />
</svg>
);
}

View file

@ -0,0 +1,116 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: React.ComponentProps<"a">) => (
<a className={className} {...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",
identifier: "PAP-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Inbox item",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
isUnreadForMe: false,
...overrides,
};
}
describe("IssueRow", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("suppresses accent hover styling when the row is selected", () => {
const root = createRoot(container);
const issue = createIssue();
act(() => {
root.render(<IssueRow issue={issue} selected />);
});
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull();
expect(link?.className).toContain("hover:bg-transparent");
expect(link?.className).not.toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
it("neutralizes selected status and unread dot accents", () => {
const root = createRoot(container);
act(() => {
root.render(<IssueRow issue={createIssue()} selected unreadState="visible" />);
});
const markReadButton = container.querySelector('button[aria-label="Mark as read"]');
const unreadDot = markReadButton?.querySelector("span");
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
expect(markReadButton).not.toBeNull();
expect(markReadButton?.className).toContain("hover:bg-muted/80");
expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20");
expect(unreadDot).not.toBeNull();
expect(unreadDot?.className).toContain("bg-muted-foreground/70");
expect(unreadDot?.className).not.toContain("bg-blue-600");
expect(statusIcon).not.toBeNull();
expect(statusIcon?.className).toContain("!border-muted-foreground");
expect(statusIcon?.className).toContain("!text-muted-foreground");
act(() => {
root.unmount();
});
});
});

View file

@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import type { Issue } from "@paperclipai/shared"; import type { Issue } from "@paperclipai/shared";
import { Link } from "@/lib/router"; import { Link } from "@/lib/router";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { StatusIcon } from "./StatusIcon"; import { StatusIcon } from "./StatusIcon";
@ -10,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading";
interface IssueRowProps { interface IssueRowProps {
issue: Issue; issue: Issue;
issueLinkState?: unknown; issueLinkState?: unknown;
selected?: boolean;
mobileLeading?: ReactNode; mobileLeading?: ReactNode;
desktopMetaLeading?: ReactNode; desktopMetaLeading?: ReactNode;
desktopLeadingSpacer?: boolean; desktopLeadingSpacer?: boolean;
@ -26,6 +28,7 @@ interface IssueRowProps {
export function IssueRow({ export function IssueRow({
issue, issue,
issueLinkState, issueLinkState,
selected = false,
mobileLeading, mobileLeading,
desktopMetaLeading, desktopMetaLeading,
desktopLeadingSpacer = false, desktopLeadingSpacer = false,
@ -42,18 +45,21 @@ export function IssueRow({
const identifier = issue.identifier ?? issue.id.slice(0, 8); const identifier = issue.identifier ?? issue.id.slice(0, 8);
const showUnreadSlot = unreadState !== null; const showUnreadSlot = unreadState !== null;
const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const showUnreadDot = unreadState === "visible" || unreadState === "fading";
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
return ( return (
<Link <Link
to={`/issues/${issuePathId}`} to={createIssueDetailPath(issuePathId, issueLinkState)}
state={issueLinkState} state={issueLinkState}
data-inbox-issue-link
className={cn( className={cn(
"group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors hover:bg-accent/50 last:border-b-0 sm:items-center sm:py-2 sm:pl-1", "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
className, className,
)} )}
> >
<span className="shrink-0 pt-px sm:hidden"> <span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} />} {mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
</span> </span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents"> <span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none"> <span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
@ -66,7 +72,7 @@ export function IssueRow({
{desktopMetaLeading ?? ( {desktopMetaLeading ?? (
<> <>
<span className="hidden shrink-0 sm:inline-flex"> <span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} /> <StatusIcon status={issue.status} className={selectedStatusClass} />
</span> </span>
<span className="shrink-0 font-mono text-xs text-muted-foreground"> <span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier} {identifier}
@ -108,12 +114,16 @@ export function IssueRow({
onMarkRead?.(); onMarkRead?.();
} }
}} }}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20",
)}
aria-label="Mark as read" aria-label="Mark as read"
> >
<span <span
className={cn( className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400", "block h-2 w-2 rounded-full transition-opacity duration-300",
selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400",
unreadState === "fading" ? "opacity-0" : "opacity-100", unreadState === "fading" ? "opacity-0" : "opacity-100",
)} )}
/> />

View file

@ -564,8 +564,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
{mentionActive && filteredMentions.length > 0 && {mentionActive && filteredMentions.length > 0 &&
createPortal( createPortal(
<div <div
className="fixed z-[9999] min-w-[180px] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md" className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{ top: mentionState.viewportTop + 4, left: mentionState.viewportLeft }} style={{
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
}}
> >
{filteredMentions.map((option, i) => ( {filteredMentions.map((option, i) => (
<button <button

View file

@ -21,6 +21,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { HermesIcon } from "./HermesIcon";
type AdvancedAdapterType = type AdvancedAdapterType =
| "claude_local" | "claude_local"
@ -29,7 +30,8 @@ type AdvancedAdapterType =
| "opencode_local" | "opencode_local"
| "pi_local" | "pi_local"
| "cursor" | "cursor"
| "openclaw_gateway"; | "openclaw_gateway"
| "hermes_local";
const ADVANCED_ADAPTER_OPTIONS: Array<{ const ADVANCED_ADAPTER_OPTIONS: Array<{
value: AdvancedAdapterType; value: AdvancedAdapterType;
@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
icon: OpenCodeLogoIcon, icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent", desc: "Local multi-provider agent",
}, },
{
value: "hermes_local",
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent",
},
{ {
value: "pi_local", value: "pi_local",
label: "Pi", label: "Pi",

View file

@ -56,12 +56,14 @@ import {
ChevronDown, ChevronDown,
X X
} from "lucide-react"; } from "lucide-react";
import { HermesIcon } from "./HermesIcon";
type Step = 1 | 2 | 3 | 4; type Step = 1 | 2 | 3 | 4;
type AdapterType = type AdapterType =
| "claude_local" | "claude_local"
| "codex_local" | "codex_local"
| "gemini_local" | "gemini_local"
| "hermes_local"
| "opencode_local" | "opencode_local"
| "pi_local" | "pi_local"
| "cursor" | "cursor"
@ -208,6 +210,7 @@ export function OnboardingWizard() {
adapterType === "claude_local" || adapterType === "claude_local" ||
adapterType === "codex_local" || adapterType === "codex_local" ||
adapterType === "gemini_local" || adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" || adapterType === "opencode_local" ||
adapterType === "pi_local" || adapterType === "pi_local" ||
adapterType === "cursor"; adapterType === "cursor";
@ -217,6 +220,8 @@ export function OnboardingWizard() {
? "codex" ? "codex"
: adapterType === "gemini_local" : adapterType === "gemini_local"
? "gemini" ? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local" : adapterType === "pi_local"
? "pi" ? "pi"
: adapterType === "cursor" : adapterType === "cursor"
@ -843,6 +848,12 @@ export function OnboardingWizard() {
icon: MousePointer2, icon: MousePointer2,
desc: "Local Cursor agent" desc: "Local Cursor agent"
}, },
{
value: "hermes_local" as const,
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent"
},
{ {
value: "openclaw_gateway" as const, value: "openclaw_gateway" as const,
label: "OpenClaw Gateway", label: "OpenClaw Gateway",
@ -902,6 +913,7 @@ export function OnboardingWizard() {
{(adapterType === "claude_local" || {(adapterType === "claude_local" ||
adapterType === "codex_local" || adapterType === "codex_local" ||
adapterType === "gemini_local" || adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" || adapterType === "opencode_local" ||
adapterType === "pi_local" || adapterType === "pi_local" ||
adapterType === "cursor") && ( adapterType === "cursor") && (

View file

@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Plus } from "lucide-react"; import { ChevronRight, Plus } from "lucide-react";
import { import {
DndContext, DndContext,
PointerSensor, MouseSensor,
closestCenter, closestCenter,
type DragEndEvent, type DragEndEvent,
useSensor, useSensor,
@ -153,7 +153,8 @@ export function SidebarProjects() {
const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/); const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/);
const activeProjectRef = projectMatch?.[1] ?? null; const activeProjectRef = projectMatch?.[1] ?? null;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { // Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior.
useSensor(MouseSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
}), }),
); );

View file

@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SwipeToArchive } from "./SwipeToArchive";
// Tell React this environment uses act() for event flushing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function dispatchTouchEvent(
node: Element,
type: "touchstart" | "touchmove" | "touchend",
coords: { x: number; y: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
const touchPoint = { clientX: coords.x, clientY: coords.y };
Object.defineProperty(event, "touches", {
configurable: true,
value: type === "touchend" ? [] : [touchPoint],
});
Object.defineProperty(event, "changedTouches", {
configurable: true,
value: [touchPoint],
});
node.dispatchEvent(event);
}
describe("SwipeToArchive", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
container.remove();
});
it("suppresses descendant clicks after a horizontal swipe and archives the row", () => {
const onArchive = vi.fn();
const onClick = vi.fn();
const root = createRoot(container);
act(() => {
root.render(
<SwipeToArchive onArchive={onArchive}>
<button type="button" onClick={onClick}>
Open issue
</button>
</SwipeToArchive>,
);
});
const wrapper = container.firstElementChild as HTMLDivElement;
const button = container.querySelector("button");
expect(button).not.toBeNull();
Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 });
Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 });
act(() => {
dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 });
});
act(() => {
dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 });
});
act(() => {
dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 });
});
act(() => {
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onClick).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(140);
});
expect(onArchive).toHaveBeenCalledTimes(1);
act(() => {
root.unmount();
});
});
it("does not suppress a normal tap click", () => {
const onArchive = vi.fn();
const onClick = vi.fn();
const root = createRoot(container);
act(() => {
root.render(
<SwipeToArchive onArchive={onArchive}>
<button type="button" onClick={onClick}>
Open issue
</button>
</SwipeToArchive>,
);
});
const button = container.querySelector("button");
expect(button).not.toBeNull();
act(() => {
button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onClick).toHaveBeenCalledTimes(1);
expect(onArchive).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("renders the selected inbox treatment on the swipe surface", () => {
const root = createRoot(container);
act(() => {
root.render(
<SwipeToArchive onArchive={() => {}} selected>
<button type="button">Open issue</button>
</SwipeToArchive>,
);
});
const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null;
expect(surface).not.toBeNull();
expect(surface?.style.backgroundColor).toBe("rgb(243, 244, 246)");
expect(surface?.style.boxShadow).toBe("");
act(() => {
root.unmount();
});
});
});

View file

@ -6,23 +6,27 @@ interface SwipeToArchiveProps {
children: ReactNode; children: ReactNode;
onArchive: () => void; onArchive: () => void;
disabled?: boolean; disabled?: boolean;
selected?: boolean;
className?: string; className?: string;
} }
const COMMIT_THRESHOLD = 0.4; const COMMIT_THRESHOLD = 0.32;
const MAX_SWIPE = 0.92; const MAX_SWIPE = 0.88;
const COMMIT_DELAY_MS = 210; const COMMIT_DELAY_MS = 140;
const SELECTED_ROW_BACKGROUND = "#f3f4f6";
export function SwipeToArchive({ export function SwipeToArchive({
children, children,
onArchive, onArchive,
disabled = false, disabled = false,
selected = false,
className, className,
}: SwipeToArchiveProps) { }: SwipeToArchiveProps) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const startPointRef = useRef<{ x: number; y: number } | null>(null); const startPointRef = useRef<{ x: number; y: number } | null>(null);
const widthRef = useRef(0); const widthRef = useRef(0);
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
const suppressClickRef = useRef(false);
const [offsetX, setOffsetX] = useState(0); const [offsetX, setOffsetX] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isCollapsing, setIsCollapsing] = useState(false); const [isCollapsing, setIsCollapsing] = useState(false);
@ -68,6 +72,7 @@ export function SwipeToArchive({
widthRef.current = node?.offsetWidth ?? 0; widthRef.current = node?.offsetWidth ?? 0;
setLockedHeight(node?.offsetHeight ?? null); setLockedHeight(node?.offsetHeight ?? null);
setIsCollapsing(false); setIsCollapsing(false);
suppressClickRef.current = false;
startPointRef.current = { x: touch.clientX, y: touch.clientY }; startPointRef.current = { x: touch.clientX, y: touch.clientY };
}; };
@ -86,6 +91,7 @@ export function SwipeToArchive({
startPointRef.current = null; startPointRef.current = null;
return; return;
} }
suppressClickRef.current = true;
} }
if (deltaX >= 0) { if (deltaX >= 0) {
@ -127,6 +133,12 @@ export function SwipeToArchive({
onTouchMove={handleTouchMove} onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd} onTouchCancel={handleTouchEnd}
onClickCapture={(event) => {
if (!suppressClickRef.current) return;
event.preventDefault();
event.stopPropagation();
suppressClickRef.current = false;
}}
> >
<div <div
aria-hidden="true" aria-hidden="true"
@ -139,10 +151,12 @@ export function SwipeToArchive({
</span> </span>
</div> </div>
<div <div
data-inbox-row-surface
className="relative bg-card will-change-transform" className="relative bg-card will-change-transform"
style={{ style={{
transform: `translate3d(${offsetX}px, 0, 0)`, transform: `translate3d(${offsetX}px, 0, 0)`,
transition: isDragging ? "none" : "transform 180ms ease-out", transition: isDragging ? "none" : "transform 180ms ease-out",
backgroundColor: selected ? SELECTED_ROW_BACKGROUND : undefined,
}} }}
> >
{children} {children}

View file

@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
opencode_local: "OpenCode (local)", opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)", cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process", process: "Process",
http: "HTTP", http: "HTTP",
}; };

View file

@ -72,6 +72,26 @@ type TranscriptBlock =
status: "running" | "completed" | "error"; status: "running" | "completed" | "error";
}>; }>;
} }
| {
type: "tool_group";
ts: string;
endTs?: string;
items: Array<{
ts: string;
endTs?: string;
name: string;
input: unknown;
result?: string;
isError?: boolean;
status: "running" | "completed" | "error";
}>;
}
| {
type: "stderr_group";
ts: string;
endTs?: string;
lines: Array<{ ts: string; text: string }>;
}
| { | {
type: "stdout"; type: "stdout";
ts: string; ts: string;
@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
return grouped; return grouped;
} }
/** Group consecutive non-command tool blocks into a single tool_group accordion. */
function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] {
const grouped: TranscriptBlock[] = [];
let pending: Array<Extract<TranscriptBlock, { type: "tool_group" }>["items"][number]> = [];
let groupTs: string | null = null;
let groupEndTs: string | undefined;
const flush = () => {
if (pending.length === 0 || !groupTs) return;
grouped.push({
type: "tool_group",
ts: groupTs,
endTs: groupEndTs,
items: pending,
});
pending = [];
groupTs = null;
groupEndTs = undefined;
};
for (const block of blocks) {
if (block.type === "tool" && !isCommandTool(block.name, block.input)) {
if (!groupTs) groupTs = block.ts;
groupEndTs = block.endTs ?? block.ts;
pending.push({
ts: block.ts,
endTs: block.endTs,
name: block.name,
input: block.input,
result: block.result,
isError: block.isError,
status: block.status,
});
continue;
}
flush();
grouped.push(block);
}
flush();
return grouped;
}
export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] {
const blocks: TranscriptBlock[] = []; const blocks: TranscriptBlock[] = [];
const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>(); const pendingToolBlocks = new Map<string, Extract<TranscriptBlock, { type: "tool" }>>();
@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
if (shouldHideNiceModeStderr(entry.text)) { if (shouldHideNiceModeStderr(entry.text)) {
continue; continue;
} }
blocks.push({ // Batch consecutive stderr entries into a single group
type: "event", const prev = blocks[blocks.length - 1];
ts: entry.ts, if (prev && prev.type === "stderr_group") {
label: "stderr", prev.lines.push({ ts: entry.ts, text: entry.text });
tone: "error", prev.endTs = entry.ts;
text: entry.text, } else {
}); blocks.push({
type: "stderr_group",
ts: entry.ts,
endTs: entry.ts,
lines: [{ ts: entry.ts, text: entry.text }],
});
}
continue; continue;
} }
@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
} }
} }
return groupCommandBlocks(blocks); return groupToolBlocks(groupCommandBlocks(blocks));
} }
function TranscriptMessageBlock({ function TranscriptMessageBlock({
@ -805,6 +873,139 @@ function TranscriptCommandGroup({
); );
} }
function TranscriptToolGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "tool_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
const runningItem = [...block.items].reverse().find((item) => item.status === "running");
const hasError = block.items.some((item) => item.status === "error");
const isRunning = Boolean(runningItem);
const uniqueNames = [...new Set(block.items.map((item) => item.name))];
const toolLabel =
uniqueNames.length === 1
? humanizeLabel(uniqueNames[0])
: `${uniqueNames.length} tools`;
const title = isRunning
? `Using ${toolLabel}`
: block.items.length === 1
? `Used ${toolLabel}`
: `Used ${toolLabel} (${block.items.length} calls)`;
const subtitle = runningItem
? summarizeToolInput(runningItem.name, runningItem.input, density)
: null;
const statusTone = isRunning
? "text-cyan-700 dark:text-cyan-300"
: "text-foreground/70";
return (
<div className="rounded-xl border border-border/40 bg-muted/[0.25]">
<div
role="button"
tabIndex={0}
className={cn("flex cursor-pointer gap-2 px-3 py-2.5", subtitle ? "items-start" : "items-center")}
onClick={() => { if (hasSelectedText()) return; setOpen((v) => !v); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
{block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => {
const isItemRunning = item.status === "running";
const isItemError = item.status === "error";
return (
<span
key={`${item.ts}-${index}`}
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
index > 0 && "-ml-1.5",
isItemRunning
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
: isItemError
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
: "border-border/70 bg-background text-foreground/55",
isItemRunning && "animate-pulse",
)}
>
<Wrench className="h-3.5 w-3.5" />
</span>
);
})}
</div>
<div className="min-w-0 flex-1">
<div className={cn("font-semibold uppercase leading-none tracking-[0.1em]", compact ? "text-[10px]" : "text-[11px]", "text-muted-foreground/70")}>
{title}
</div>
{subtitle && (
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
{subtitle}
</div>
)}
</div>
<button
type="button"
className={cn("inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", subtitle && "mt-0.5")}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
aria-label={open ? "Collapse tool details" : "Expand tool details"}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</div>
{open && (
<div className={cn("space-y-2 border-t border-border/30 px-3 py-3", hasError && "rounded-b-xl")}>
{block.items.map((item, index) => (
<div key={`${item.ts}-${index}`} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
item.status === "error"
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
: item.status === "running"
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
: "border-border/70 bg-background text-foreground/55",
)}>
<Wrench className="h-3 w-3" />
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground")}>
{humanizeLabel(item.name)}
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]",
item.status === "running" ? "text-cyan-700 dark:text-cyan-300"
: item.status === "error" ? "text-red-700 dark:text-red-300"
: "text-emerald-700 dark:text-emerald-300"
)}>
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
</span>
</div>
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Input</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
{formatToolPayload(item.input) || "<empty>"}
</pre>
</div>
{item.result && (
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Result</div>
<pre className={cn(
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
)}>
{formatToolPayload(item.result)}
</pre>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
function TranscriptActivityRow({ function TranscriptActivityRow({
block, block,
density, density,
@ -883,6 +1084,43 @@ function TranscriptEventRow({
); );
} }
function TranscriptStderrGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "stderr_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
return (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] p-2 text-amber-700 dark:text-amber-300">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2"
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]")}>
{block.lines.length} log {block.lines.length === 1 ? "line" : "lines"}
</span>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</div>
{open && (
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-amber-700/80 dark:text-amber-300/80 pl-5">
{block.lines.map((line, i) => (
<span key={`${line.ts}-${i}`}>
<span className="select-none text-amber-500/50 dark:text-amber-400/40">{i > 0 ? "\n" : ""}</span>
{line.text}
</span>
))}
</pre>
)}
</div>
);
}
function TranscriptStdoutRow({ function TranscriptStdoutRow({
block, block,
density, density,
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
)} )}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />} {block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />} {block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
{block.type === "stdout" && ( {block.type === "stdout" && (
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} /> <TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
)} )}

View file

@ -64,7 +64,16 @@ export function useReadInboxItems() {
}); });
}; };
return { readItems, markRead }; const markUnread = (id: string) => {
setReadItems((prev) => {
const next = new Set(prev);
next.delete(id);
saveReadInboxItems(next);
return next;
});
};
return { readItems, markRead, markUnread };
} }
export function useInboxBadge(companyId: string | null | undefined) { export function useInboxBadge(companyId: string | null | undefined) {

View file

@ -343,6 +343,17 @@
margin-top: 1.1em; margin-top: 1.1em;
} }
.paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) {
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
text-decoration: underline;
text-underline-offset: 0.15em;
cursor: pointer;
}
.dark .paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) {
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
}
.paperclip-mdxeditor-content a.paperclip-mention-chip, .paperclip-mdxeditor-content a.paperclip-mention-chip,
.paperclip-mdxeditor-content a.paperclip-project-mention-chip { .paperclip-mdxeditor-content a.paperclip-project-mention-chip {
display: inline-flex; display: inline-flex;
@ -661,12 +672,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
.paperclip-markdown a { .paperclip-markdown a {
color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%);
text-decoration: none;
}
.paperclip-markdown a:hover {
text-decoration: underline; text-decoration: underline;
text-underline-offset: 0.15em; text-underline-offset: 0.15em;
cursor: pointer;
}
.paperclip-markdown a.paperclip-mention-chip {
text-decoration: none;
} }
.dark .paperclip-markdown a { .dark .paperclip-markdown a {

View file

@ -6,10 +6,13 @@ import {
computeInboxBadgeData, computeInboxBadgeData,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getRecentTouchedIssues, getRecentTouchedIssues,
getUnreadTouchedIssues, getUnreadTouchedIssues,
isMineInboxTab,
loadLastInboxTab, loadLastInboxTab,
RECENT_ISSUES_LIMIT, RECENT_ISSUES_LIMIT,
resolveInboxSelectionIndex,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
} from "./inbox"; } from "./inbox";
@ -400,4 +403,24 @@ describe("inbox helpers", () => {
localStorage.setItem("paperclip:inbox:last-tab", "new"); localStorage.setItem("paperclip:inbox:last-tab", "new");
expect(loadLastInboxTab()).toBe("mine"); expect(loadLastInboxTab()).toBe("mine");
}); });
it("enables swipe archive only on the mine tab", () => {
expect(isMineInboxTab("mine")).toBe(true);
expect(isMineInboxTab("recent")).toBe(false);
expect(isMineInboxTab("unread")).toBe(false);
expect(isMineInboxTab("all")).toBe(false);
});
it("anchors Mine selection to the first available inbox row", () => {
expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1);
expect(resolveInboxSelectionIndex(5, 3)).toBe(2);
expect(resolveInboxSelectionIndex(1, 0)).toBe(-1);
});
it("selects the first row only after keyboard navigation starts", () => {
expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0);
expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0);
expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1);
expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0);
});
}); });

View file

@ -98,6 +98,31 @@ export function saveLastInboxTab(tab: InboxTab) {
} }
} }
export function isMineInboxTab(tab: InboxTab): boolean {
return tab === "mine";
}
export function resolveInboxSelectionIndex(
previousIndex: number,
itemCount: number,
): number {
if (itemCount === 0) return -1;
if (previousIndex < 0) return -1;
return Math.min(previousIndex, itemCount - 1);
}
export function getInboxKeyboardSelectionIndex(
previousIndex: number,
itemCount: number,
direction: "next" | "previous",
): number {
if (itemCount === 0) return -1;
if (previousIndex < 0) return 0;
return direction === "next"
? Math.min(previousIndex + 1, itemCount - 1)
: Math.max(previousIndex - 1, 0);
}
export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] {
const sorted = [...runs].sort( const sorted = [...runs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
createIssueDetailLocationState,
createIssueDetailPath,
readIssueDetailBreadcrumb,
} from "./issueDetailBreadcrumb";
describe("issueDetailBreadcrumb", () => {
it("prefers the full breadcrumb from route state", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({
label: "Inbox",
href: "/inbox/mine",
});
});
it("falls back to the source query param when route state is unavailable", () => {
expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({
label: "Inbox",
href: "/inbox",
});
});
it("adds the source query param when building an issue detail path", () => {
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
});
it("reuses the current source query param when state has been dropped", () => {
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
});
});

View file

@ -1,3 +1,5 @@
type IssueDetailSource = "issues" | "inbox";
type IssueDetailBreadcrumb = { type IssueDetailBreadcrumb = {
label: string; label: string;
href: string; href: string;
@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = {
type IssueDetailLocationState = { type IssueDetailLocationState = {
issueDetailBreadcrumb?: IssueDetailBreadcrumb; issueDetailBreadcrumb?: IssueDetailBreadcrumb;
issueDetailSource?: IssueDetailSource;
}; };
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
if (typeof value !== "object" || value === null) return false; if (typeof value !== "object" || value === null) return false;
const candidate = value as Partial<IssueDetailBreadcrumb>; const candidate = value as Partial<IssueDetailBreadcrumb>;
return typeof candidate.label === "string" && typeof candidate.href === "string"; return typeof candidate.label === "string" && typeof candidate.href === "string";
} }
export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState { function isIssueDetailSource(value: unknown): value is IssueDetailSource {
return { issueDetailBreadcrumb: { label, href } }; return value === "issues" || value === "inbox";
} }
export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null { function readIssueDetailSource(state: unknown): IssueDetailSource | null {
if (typeof state !== "object" || state === null) return null; if (typeof state !== "object" || state === null) return null;
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; const source = (state as IssueDetailLocationState).issueDetailSource;
return isIssueDetailBreadcrumb(candidate) ? candidate : null; return isIssueDetailSource(source) ? source : null;
}
function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null {
if (!search) return null;
const params = new URLSearchParams(search);
const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM);
return isIssueDetailSource(source) ? source : null;
}
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
return { label: "Issues", href: "/issues" };
}
export function createIssueDetailLocationState(
label: string,
href: string,
source?: IssueDetailSource,
): IssueDetailLocationState {
return {
issueDetailBreadcrumb: { label, href },
issueDetailSource: source,
};
}
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
if (!source) return `/issues/${issuePathId}`;
const params = new URLSearchParams();
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
return `/issues/${issuePathId}?${params.toString()}`;
}
export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null {
if (typeof state === "object" && state !== null) {
const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb;
if (isIssueDetailBreadcrumb(candidate)) return candidate;
}
const source = readIssueDetailSourceFromSearch(search);
return source ? breadcrumbForSource(source) : null;
} }

View file

@ -0,0 +1,215 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
isQueuedIssueComment,
mergeIssueComments,
upsertIssueComment,
} from "./optimistic-issue-comments";
describe("optimistic issue comments", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("creates a pending optimistic comment for the current user", () => {
const comment = createOptimisticIssueComment({
companyId: "company-1",
issueId: "issue-1",
body: "Working on it",
authorUserId: "board-1",
});
expect(comment.id).toMatch(/^optimistic-/);
expect(comment.clientId).toBe(comment.id);
expect(comment.clientStatus).toBe("pending");
expect(comment.authorUserId).toBe("board-1");
expect(comment.authorAgentId).toBeNull();
});
it("falls back when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {});
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_746_000_000_000);
const mathSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456789);
const comment = createOptimisticIssueComment({
companyId: "company-1",
issueId: "issue-1",
body: "Working on it",
authorUserId: "board-1",
});
expect(comment.id).toBe("optimistic-1746000000000-4fzzzxjy");
expect(comment.clientId).toBe(comment.id);
nowSpy.mockRestore();
mathSpy.mockRestore();
});
it("supports queued optimistic comments for active-run follow-ups", () => {
const comment = createOptimisticIssueComment({
companyId: "company-1",
issueId: "issue-1",
body: "Queue this",
authorUserId: "board-1",
clientStatus: "queued",
queueTargetRunId: "run-1",
});
expect(comment.clientStatus).toBe("queued");
expect(comment.queueTargetRunId).toBe("run-1");
});
it("merges optimistic comments into the server thread in chronological order", () => {
const merged = mergeIssueComments(
[
{
id: "comment-2",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Second",
createdAt: new Date("2026-03-28T14:00:02.000Z"),
updatedAt: new Date("2026-03-28T14:00:02.000Z"),
},
],
[
{
id: "optimistic-1",
clientId: "optimistic-1",
clientStatus: "pending",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "First",
createdAt: new Date("2026-03-28T14:00:01.000Z"),
updatedAt: new Date("2026-03-28T14:00:01.000Z"),
},
],
);
expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]);
});
it("upserts confirmed comments without creating duplicates", () => {
const next = upsertIssueComment(
[
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Original",
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
],
{
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "board-1",
body: "Updated",
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:05.000Z"),
},
);
expect(next).toHaveLength(1);
expect(next[0]?.body).toBe("Updated");
});
it("applies optimistic reopen and reassignment updates to the issue cache", () => {
const next = applyOptimisticIssueCommentUpdate(
{
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Fix comment flow",
description: null,
status: "done",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: "board-1",
issueNumber: 1,
identifier: "PAP-1",
originKind: "manual",
originId: null,
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-28T14:00:00.000Z"),
updatedAt: new Date("2026-03-28T14:00:00.000Z"),
},
{
reopen: true,
reassignment: {
assigneeAgentId: null,
assigneeUserId: "board-2",
},
},
);
expect(next?.status).toBe("todo");
expect(next?.assigneeAgentId).toBeNull();
expect(next?.assigneeUserId).toBe("board-2");
});
it("treats comments without a run id as queued when they arrive during an active run", () => {
expect(
isQueuedIssueComment({
comment: {
createdAt: new Date("2026-03-28T16:20:05.000Z"),
},
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
runId: null,
}),
).toBe(true);
});
it("does not mark comments with an associated run as queued", () => {
expect(
isQueuedIssueComment({
comment: {
createdAt: new Date("2026-03-28T16:20:05.000Z"),
},
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
runId: "run-1",
}),
).toBe(false);
});
it("does not mark interrupt comments as queued", () => {
expect(
isQueuedIssueComment({
comment: {
createdAt: new Date("2026-03-28T16:20:05.000Z"),
},
activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"),
interruptedRunId: "run-1",
}),
).toBe(false);
});
});

View file

@ -0,0 +1,123 @@
import type { Issue, IssueComment } from "@paperclipai/shared";
export interface IssueCommentReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface OptimisticIssueComment extends IssueComment {
clientId: string;
clientStatus: "pending" | "queued";
queueTargetRunId?: string | null;
}
export type IssueTimelineComment = IssueComment | OptimisticIssueComment;
function toTimestamp(value: Date | string) {
return new Date(value).getTime();
}
function createOptimisticCommentId() {
const randomUuid = globalThis.crypto?.randomUUID?.();
if (randomUuid) {
return `optimistic-${randomUuid}`;
}
return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export function sortIssueComments<T extends { createdAt: Date | string; id: string }>(comments: T[]) {
return [...comments].sort((a, b) => {
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
if (createdAtDiff !== 0) return createdAtDiff;
return a.id.localeCompare(b.id);
});
}
export function createOptimisticIssueComment(params: {
companyId: string;
issueId: string;
body: string;
authorUserId: string | null;
clientStatus?: OptimisticIssueComment["clientStatus"];
queueTargetRunId?: string | null;
}): OptimisticIssueComment {
const now = new Date();
const clientId = createOptimisticCommentId();
return {
id: clientId,
clientId,
companyId: params.companyId,
issueId: params.issueId,
authorAgentId: null,
authorUserId: params.authorUserId,
body: params.body,
clientStatus: params.clientStatus ?? "pending",
queueTargetRunId: params.queueTargetRunId ?? null,
createdAt: now,
updatedAt: now,
};
}
export function isQueuedIssueComment(params: {
comment: Pick<IssueTimelineComment, "createdAt"> & Partial<Pick<OptimisticIssueComment, "clientStatus">>;
activeRunStartedAt?: Date | string | null;
runId?: string | null;
interruptedRunId?: string | null;
}) {
if (params.runId) return false;
if (params.interruptedRunId) return false;
if (params.comment.clientStatus === "queued") return true;
if (!params.activeRunStartedAt) return false;
return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt);
}
export function mergeIssueComments(
comments: IssueComment[] | undefined,
optimisticComments: OptimisticIssueComment[],
): IssueTimelineComment[] {
const merged = [...(comments ?? [])];
const existingIds = new Set(merged.map((comment) => comment.id));
for (const comment of optimisticComments) {
if (!existingIds.has(comment.id)) {
merged.push(comment);
}
}
return sortIssueComments(merged);
}
export function upsertIssueComment(
comments: IssueComment[] | undefined,
nextComment: IssueComment,
): IssueComment[] {
const current = comments ?? [];
const existingIndex = current.findIndex((comment) => comment.id === nextComment.id);
if (existingIndex === -1) {
return sortIssueComments([...current, nextComment]);
}
const updated = [...current];
updated[existingIndex] = nextComment;
return sortIssueComments(updated);
}
export function applyOptimisticIssueCommentUpdate(
issue: Issue | undefined,
params: {
reopen?: boolean;
reassignment?: IssueCommentReassignment;
},
) {
if (!issue) return issue;
const nextIssue: Issue = { ...issue };
if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) {
nextIssue.status = "todo";
}
if (params.reassignment) {
nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId;
nextIssue.assigneeUserId = params.reassignment.assigneeUserId;
}
return nextIssue;
}

View file

@ -25,6 +25,8 @@ export const queryKeys = {
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
adapterModels: (companyId: string, adapterType: string) => adapterModels: (companyId: string, adapterType: string) =>
["agents", companyId, "adapter-models", adapterType] as const, ["agents", companyId, "adapter-models", adapterType] as const,
detectModel: (companyId: string, adapterType: string) =>
["agents", companyId, "detect-model", adapterType] as const,
}, },
issues: { issues: {
list: (companyId: string) => ["issues", companyId] as const, list: (companyId: string) => ["issues", companyId] as const,

View file

@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin
const isLive = run.status === "running" || run.status === "queued"; const isLive = run.status === "running" || run.status === "queued";
const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" };
const StatusIcon = statusInfo.icon; const StatusIcon = statusInfo.icon;
const summary = run.resultJson const summaryRaw = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "") ? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? ""; : run.error ?? "";
// Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines
const summary = useMemo(() => {
if (!summaryRaw) return "";
const lines = summaryRaw
.replace(/^#{1,6}\s+/gm, "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l));
const excerpt: string[] = [];
let chars = 0;
for (const line of lines) {
if (excerpt.length >= 3 || chars + line.length > 280) break;
excerpt.push(line);
chars += line.length;
}
return excerpt.join(" ");
}, [summaryRaw]);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]); const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]); const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
const lastSavedSkillsRef = useRef<string[]>([]); const lastSavedSkillsRef = useRef<string[]>([]);
const hasHydratedSkillSnapshotRef = useRef(false); const hasHydratedSkillSnapshotRef = useRef(false);
const skipNextSkillAutosaveRef = useRef(true); const skipNextSkillAutosaveRef = useRef(true);
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
{unmanagedSkillRows.length > 0 && ( {unmanagedSkillRows.length > 0 && (
<section className="border-y border-border"> <section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2"> <div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 select-none"
onClick={() => setUnmanagedOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }}
>
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
User-installed skills, not managed by Paperclip ({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip
</span> </span>
{unmanagedOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
</div> </div>
{unmanagedSkillRows.map(renderSkillRow)} {unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
</section> </section>
)} )}
</> </>

View file

@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
gemini_local: "Gemini", gemini_local: "Gemini",
opencode_local: "OpenCode", opencode_local: "OpenCode",
cursor: "Cursor", cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",
process: "Process", process: "Process",
http: "HTTP", http: "HTTP",

181
ui/src/pages/Inbox.test.tsx Normal file
View file

@ -0,0 +1,181 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { FailedRunInboxRow, InboxIssueMetaLeading } from "./Inbox";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
<a className={className} {...props}>{children}</a>
),
useLocation: () => ({ pathname: "/", search: "", hash: "" }),
useNavigate: () => () => {},
}));
// 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",
identifier: "PAP-904",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Inbox item",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 904,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
isUnreadForMe: false,
...overrides,
};
}
describe("FailedRunInboxRow", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("suppresses accent hover styling when selected", () => {
const root = createRoot(container);
const run = {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
invocationSource: "assignment",
triggerDetail: null,
status: "failed",
error: "boom",
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
errorCode: null,
externalRunId: null,
processPid: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
startedAt: new Date("2026-03-11T00:00:00.000Z"),
finishedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
} as const;
act(() => {
root.render(
<FailedRunInboxRow
run={run}
issueById={new Map()}
agentName="Agent"
issueLinkState={null}
onDismiss={() => {}}
onRetry={() => {}}
isRetrying={false}
selected
/>,
);
});
const link = container.querySelector("a");
expect(link).not.toBeNull();
expect(link?.className).toContain("hover:bg-transparent");
expect(link?.className).not.toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
});
describe("InboxIssueMetaLeading", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("neutralizes selected status and live accents", () => {
const root = createRoot(container);
act(() => {
root.render(<InboxIssueMetaLeading issue={createIssue()} selected isLive />);
});
const statusIcon = container.querySelector('span[class*="border-muted-foreground"]');
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-muted"]');
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
(node) => node.textContent === "Live" && node.className.includes("text-"),
);
const liveDot = container.querySelector('span[class*="bg-muted-foreground/70"]');
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
expect(statusIcon).not.toBeNull();
expect(statusIcon?.className).toContain("!border-muted-foreground");
expect(statusIcon?.className).toContain("!text-muted-foreground");
expect(liveBadge).not.toBeNull();
expect(liveBadge?.className).toContain("bg-muted");
expect(liveBadgeLabel).not.toBeNull();
expect(liveBadgeLabel?.className).toContain("text-muted-foreground");
expect(liveBadgeLabel?.className).not.toContain("text-blue-600");
expect(liveDot).not.toBeNull();
expect(pulseRing).toBeNull();
act(() => {
root.unmount();
});
});
});

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation, useNavigate } from "@/lib/router"; import { Link, useLocation, useNavigate } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
import { approvalsApi } from "../api/approvals"; import { approvalsApi } from "../api/approvals";
import { accessApi } from "../api/access"; import { accessApi } from "../api/access";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
@ -11,7 +12,7 @@ import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailLocationState, createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { EmptyState } from "../components/EmptyState"; import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton"; import { PageSkeleton } from "../components/PageSkeleton";
import { IssueRow } from "../components/IssueRow"; import { IssueRow } from "../components/IssueRow";
@ -46,12 +47,16 @@ import {
ACTIONABLE_APPROVAL_STATUSES, ACTIONABLE_APPROVAL_STATUSES,
getApprovalsForTab, getApprovalsForTab,
getInboxWorkItems, getInboxWorkItems,
getInboxKeyboardSelectionIndex,
getLatestFailedRunsByAgent, getLatestFailedRunsByAgent,
getRecentTouchedIssues, getRecentTouchedIssues,
isMineInboxTab,
resolveInboxSelectionIndex,
InboxApprovalFilter, InboxApprovalFilter,
saveLastInboxTab, saveLastInboxTab,
shouldShowInboxSection, shouldShowInboxSection,
type InboxTab, type InboxTab,
type InboxWorkItem,
} from "../lib/inbox"; } from "../lib/inbox";
import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge"; import { useDismissedInboxItems, useReadInboxItems } from "../hooks/useInboxBadge";
@ -66,8 +71,6 @@ type SectionKey =
| "work_items" | "work_items"
| "alerts"; | "alerts";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
function firstNonEmptyLine(value: string | null | undefined): string | null { function firstNonEmptyLine(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean); const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
@ -97,8 +100,69 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
type NonIssueUnreadState = "visible" | "fading" | "hidden" | null; type NonIssueUnreadState = "visible" | "fading" | "hidden" | null;
const selectedInboxAccentClass = "!text-muted-foreground !border-muted-foreground";
function FailedRunInboxRow({ function getSelectedUnreadButtonClass(selected: boolean): string {
return selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20";
}
function getSelectedUnreadDotClass(selected: boolean): string {
return selected ? "bg-muted-foreground/70" : "bg-blue-600 dark:bg-blue-400";
}
export function InboxIssueMetaLeading({
issue,
selected,
isLive,
}: {
issue: Issue;
selected: boolean;
isLive: boolean;
}) {
return (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon
status={issue.status}
className={selected ? selectedInboxAccentClass : undefined}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{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",
selected ? "bg-muted" : "bg-blue-500/10",
)}
>
<span className="relative flex h-2 w-2">
{!selected ? (
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
) : null}
<span
className={cn(
"relative inline-flex h-2 w-2 rounded-full",
selected ? "bg-muted-foreground/70" : "bg-blue-500",
)}
/>
</span>
<span
className={cn(
"hidden text-[11px] font-medium sm:inline",
selected ? "text-muted-foreground" : "text-blue-600 dark:text-blue-400",
)}
>
Live
</span>
</span>
)}
</>
);
}
export function FailedRunInboxRow({
run, run,
issueById, issueById,
agentName: linkedAgentName, agentName: linkedAgentName,
@ -110,6 +174,7 @@ function FailedRunInboxRow({
onMarkRead, onMarkRead,
onArchive, onArchive,
archiveDisabled, archiveDisabled,
selected = false,
className, className,
}: { }: {
run: HeartbeatRun; run: HeartbeatRun;
@ -123,6 +188,7 @@ function FailedRunInboxRow({
onMarkRead?: () => void; onMarkRead?: () => void;
onArchive?: () => void; onArchive?: () => void;
archiveDisabled?: boolean; archiveDisabled?: boolean;
selected?: boolean;
className?: string; className?: string;
}) { }) {
const issueId = readIssueIdFromRun(run); const issueId = readIssueIdFromRun(run);
@ -143,11 +209,15 @@ function FailedRunInboxRow({
<button <button
type="button" type="button"
onClick={onMarkRead} onClick={onMarkRead}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read" aria-label="Mark as read"
> >
<span className={cn( <span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400", "block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100", unreadState === "fading" ? "opacity-0" : "opacity-100",
)} /> )} />
</button> </button>
@ -168,7 +238,10 @@ function FailedRunInboxRow({
) : null} ) : null}
<Link <Link
to={`/agents/${run.agentId}/runs/${run.id}`} to={`/agents/${run.agentId}/runs/${run.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50" className={cn(
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
)}
> >
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />} {!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" /> <span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -257,6 +330,7 @@ function ApprovalInboxRow({
onMarkRead, onMarkRead,
onArchive, onArchive,
archiveDisabled, archiveDisabled,
selected = false,
className, className,
}: { }: {
approval: Approval; approval: Approval;
@ -268,6 +342,7 @@ function ApprovalInboxRow({
onMarkRead?: () => void; onMarkRead?: () => void;
onArchive?: () => void; onArchive?: () => void;
archiveDisabled?: boolean; archiveDisabled?: boolean;
selected?: boolean;
className?: string; className?: string;
}) { }) {
const Icon = typeIcon[approval.type] ?? defaultTypeIcon; const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
@ -290,11 +365,15 @@ function ApprovalInboxRow({
<button <button
type="button" type="button"
onClick={onMarkRead} onClick={onMarkRead}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read" aria-label="Mark as read"
> >
<span className={cn( <span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400", "block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100", unreadState === "fading" ? "opacity-0" : "opacity-100",
)} /> )} />
</button> </button>
@ -315,7 +394,10 @@ function ApprovalInboxRow({
) : null} ) : null}
<Link <Link
to={`/approvals/${approval.id}`} to={`/approvals/${approval.id}`}
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50" className={cn(
"flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors",
selected ? "hover:bg-transparent" : "hover:bg-accent/50",
)}
> >
{!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />} {!showUnreadSlot && <span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />}
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" /> <span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
@ -389,6 +471,7 @@ function JoinRequestInboxRow({
onMarkRead, onMarkRead,
onArchive, onArchive,
archiveDisabled, archiveDisabled,
selected = false,
className, className,
}: { }: {
joinRequest: JoinRequest; joinRequest: JoinRequest;
@ -399,6 +482,7 @@ function JoinRequestInboxRow({
onMarkRead?: () => void; onMarkRead?: () => void;
onArchive?: () => void; onArchive?: () => void;
archiveDisabled?: boolean; archiveDisabled?: boolean;
selected?: boolean;
className?: string; className?: string;
}) { }) {
const label = const label =
@ -420,11 +504,15 @@ function JoinRequestInboxRow({
<button <button
type="button" type="button"
onClick={onMarkRead} onClick={onMarkRead}
className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" className={cn(
"inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors",
getSelectedUnreadButtonClass(selected),
)}
aria-label="Mark as read" aria-label="Mark as read"
> >
<span className={cn( <span className={cn(
"block h-2 w-2 rounded-full bg-blue-600 transition-opacity duration-300 dark:bg-blue-400", "block h-2 w-2 rounded-full transition-opacity duration-300",
getSelectedUnreadDotClass(selected),
unreadState === "fading" ? "opacity-0" : "opacity-100", unreadState === "fading" ? "opacity-0" : "opacity-100",
)} /> )} />
</button> </button>
@ -512,18 +600,20 @@ export function Inbox() {
const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything"); const [allCategoryFilter, setAllCategoryFilter] = useState<InboxCategoryFilter>("everything");
const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all"); const [allApprovalFilter, setAllApprovalFilter] = useState<InboxApprovalFilter>("all");
const { dismissed, dismiss } = useDismissedInboxItems(); const { dismissed, dismiss } = useDismissedInboxItems();
const { readItems, markRead: markItemRead } = useReadInboxItems(); const { readItems, markRead: markItemRead, markUnread: markItemUnread } = useReadInboxItems();
const pathSegment = location.pathname.split("/").pop() ?? "mine"; const pathSegment = location.pathname.split("/").pop() ?? "mine";
const tab: InboxTab = const tab: InboxTab =
pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread" pathSegment === "mine" || pathSegment === "recent" || pathSegment === "all" || pathSegment === "unread"
? pathSegment ? pathSegment
: "mine"; : "mine";
const canArchiveFromTab = isMineInboxTab(tab);
const issueLinkState = useMemo( const issueLinkState = useMemo(
() => () =>
createIssueDetailLocationState( createIssueDetailLocationState(
"Inbox", "Inbox",
`${location.pathname}${location.search}${location.hash}`, `${location.pathname}${location.search}${location.hash}`,
"inbox",
), ),
[location.pathname, location.search, location.hash], [location.pathname, location.search, location.hash],
); );
@ -540,6 +630,7 @@ export function Inbox() {
useEffect(() => { useEffect(() => {
saveLastInboxTab(tab); saveLastInboxTab(tab);
setSelectedIndex(-1);
}, [tab]); }, [tab]);
const { const {
@ -591,7 +682,7 @@ export function Inbox() {
issuesApi.list(selectedCompanyId!, { issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me", touchedByUserId: "me",
inboxArchivedByUserId: "me", inboxArchivedByUserId: "me",
status: INBOX_ISSUE_STATUSES, status: INBOX_MINE_ISSUE_STATUS_FILTER,
}), }),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
@ -603,7 +694,7 @@ export function Inbox() {
queryFn: () => queryFn: () =>
issuesApi.list(selectedCompanyId!, { issuesApi.list(selectedCompanyId!, {
touchedByUserId: "me", touchedByUserId: "me",
status: INBOX_ISSUE_STATUSES, status: INBOX_MINE_ISSUE_STATUS_FILTER,
}), }),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
@ -793,6 +884,8 @@ export function Inbox() {
const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set()); const [archivingIssueIds, setArchivingIssueIds] = useState<Set<string>>(new Set());
const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set()); const [fadingNonIssueItems, setFadingNonIssueItems] = useState<Set<string>>(new Set());
const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set()); const [archivingNonIssueIds, setArchivingNonIssueIds] = useState<Set<string>>(new Set());
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const listRef = useRef<HTMLDivElement>(null);
const invalidateInboxIssueQueries = () => { const invalidateInboxIssueQueries = () => {
if (!selectedCompanyId) return; if (!selectedCompanyId) return;
@ -875,7 +968,14 @@ export function Inbox() {
}, },
}); });
const handleMarkNonIssueRead = (key: string) => { const markUnreadMutation = useMutation({
mutationFn: (id: string) => issuesApi.markUnread(id),
onSuccess: () => {
invalidateInboxIssueQueries();
},
});
const handleMarkNonIssueRead = useCallback((key: string) => {
setFadingNonIssueItems((prev) => new Set(prev).add(key)); setFadingNonIssueItems((prev) => new Set(prev).add(key));
markItemRead(key); markItemRead(key);
setTimeout(() => { setTimeout(() => {
@ -885,9 +985,9 @@ export function Inbox() {
return next; return next;
}); });
}, 300); }, 300);
}; }, [markItemRead]);
const handleArchiveNonIssue = (key: string) => { const handleArchiveNonIssue = useCallback((key: string) => {
setArchivingNonIssueIds((prev) => new Set(prev).add(key)); setArchivingNonIssueIds((prev) => new Set(prev).add(key));
setTimeout(() => { setTimeout(() => {
dismiss(key); dismiss(key);
@ -897,10 +997,10 @@ export function Inbox() {
return next; return next;
}); });
}, 200); }, 200);
}; }, [dismiss]);
const nonIssueUnreadState = (key: string): NonIssueUnreadState => { const nonIssueUnreadState = (key: string): NonIssueUnreadState => {
if (tab !== "mine") return null; if (!canArchiveFromTab) return null;
const isRead = readItems.has(key); const isRead = readItems.has(key);
const isFading = fadingNonIssueItems.has(key); const isFading = fadingNonIssueItems.has(key);
if (isFading) return "fading"; if (isFading) return "fading";
@ -908,6 +1008,170 @@ export function Inbox() {
return "hidden"; return "hidden";
}; };
const getWorkItemKey = useCallback((item: InboxWorkItem): string => {
if (item.kind === "issue") return `issue:${item.issue.id}`;
if (item.kind === "approval") return `approval:${item.approval.id}`;
if (item.kind === "failed_run") return `run:${item.run.id}`;
return `join:${item.joinRequest.id}`;
}, []);
// Keep selection valid when the list shape changes, but do not auto-select on initial load.
useEffect(() => {
setSelectedIndex((prev) => resolveInboxSelectionIndex(prev, workItemsToRender.length));
}, [workItemsToRender.length]);
// Use refs for keyboard handler to avoid stale closures
const kbStateRef = useRef({
workItems: workItemsToRender,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
readItems,
});
kbStateRef.current = {
workItems: workItemsToRender,
selectedIndex,
canArchive: canArchiveFromTab,
archivingIssueIds,
archivingNonIssueIds,
fadingOutIssues,
readItems,
};
const kbActionsRef = useRef({
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
archiveNonIssue: handleArchiveNonIssue,
markRead: (id: string) => markReadMutation.mutate(id),
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
navigate,
});
kbActionsRef.current = {
archiveIssue: (id: string) => archiveIssueMutation.mutate(id),
archiveNonIssue: handleArchiveNonIssue,
markRead: (id: string) => markReadMutation.mutate(id),
markUnreadIssue: (id: string) => markUnreadMutation.mutate(id),
markNonIssueRead: handleMarkNonIssueRead,
markNonIssueUnread: markItemUnread,
navigate,
};
// Keyboard shortcuts (mail-client style) — single stable listener using refs
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
// Don't capture when typing in inputs/textareas or with modifier keys
const target = e.target;
if (
!(target instanceof HTMLElement) ||
target.closest("input, textarea, select, [contenteditable='true'], [role='textbox'], [role='combobox']") ||
target.isContentEditable ||
document.querySelector("[role='dialog'], [aria-modal='true']") ||
e.metaKey ||
e.ctrlKey ||
e.altKey
) {
return;
}
const st = kbStateRef.current;
const act = kbActionsRef.current;
// Keyboard shortcuts are only active on the "mine" tab
if (!st.canArchive) return;
const itemCount = st.workItems.length;
if (itemCount === 0) return;
switch (e.key) {
case "j": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "next"));
break;
}
case "k": {
e.preventDefault();
setSelectedIndex((prev) => getInboxKeyboardSelectionIndex(prev, itemCount, "previous"));
break;
}
case "a":
case "y": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (!st.archivingIssueIds.has(item.issue.id)) {
act.archiveIssue(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.archivingNonIssueIds.has(key)) {
act.archiveNonIssue(key);
}
}
break;
}
case "U": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
act.markUnreadIssue(item.issue.id);
} else {
act.markNonIssueUnread(getWorkItemKey(item));
}
break;
}
case "r": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
if (item.issue.isUnreadForMe && !st.fadingOutIssues.has(item.issue.id)) {
act.markRead(item.issue.id);
}
} else {
const key = getWorkItemKey(item);
if (!st.readItems.has(key)) {
act.markNonIssueRead(key);
}
}
break;
}
case "Enter": {
if (st.selectedIndex < 0 || st.selectedIndex >= itemCount) return;
e.preventDefault();
const item = st.workItems[st.selectedIndex];
if (item.kind === "issue") {
const pathId = item.issue.identifier ?? item.issue.id;
act.navigate(createIssueDetailPath(pathId, issueLinkState), { state: issueLinkState });
} else if (item.kind === "approval") {
act.navigate(`/approvals/${item.approval.id}`);
} else if (item.kind === "failed_run") {
act.navigate(`/agents/${item.run.agentId}/runs/${item.run.id}`);
}
break;
}
default:
return;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [getWorkItemKey, issueLinkState]);
// Scroll selected item into view
useEffect(() => {
if (selectedIndex < 0 || !listRef.current) return;
const rows = listRef.current.querySelectorAll("[data-inbox-item]");
const row = rows[selectedIndex];
if (row) row.scrollIntoView({ block: "nearest" });
}, [selectedIndex]);
if (!selectedCompanyId) { if (!selectedCompanyId) {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />; return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
} }
@ -953,25 +1217,25 @@ export function Inbox() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2"> <Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}>
<Tabs value={tab} onValueChange={(value) => navigate(`/inbox/${value}`)}> <PageTabBar
<PageTabBar items={[
items={[ {
{ value: "mine",
value: "mine", label: "Mine",
label: "Mine", },
}, {
{ value: "recent",
value: "recent", label: "Recent",
label: "Recent", },
}, { value: "unread", label: "Unread" },
{ value: "unread", label: "Unread" }, { value: "all", label: "All" },
{ value: "all", label: "All" }, ]}
]} />
/> </Tabs>
</Tabs>
<div className="flex items-center gap-2">
{canMarkAllRead && ( {canMarkAllRead && (
<Button <Button
type="button" type="button"
@ -985,44 +1249,44 @@ export function Inbox() {
</Button> </Button>
)} )}
</div> </div>
</div>
{tab === "all" && ( {tab === "all" && (
<div className="flex flex-wrap items-center gap-2 sm:justify-end"> <div className="flex flex-wrap items-center gap-2">
<Select
value={allCategoryFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="everything">All categories</SelectItem>
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
<SelectItem value="join_requests">Join requests</SelectItem>
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
</SelectContent>
</Select>
{showApprovalsCategory && (
<Select <Select
value={allCategoryFilter} value={allApprovalFilter}
onValueChange={(value) => setAllCategoryFilter(value as InboxCategoryFilter)} onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
> >
<SelectTrigger className="h-8 w-[170px] text-xs"> <SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Category" /> <SelectValue placeholder="Approval status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="everything">All categories</SelectItem> <SelectItem value="all">All approval statuses</SelectItem>
<SelectItem value="issues_i_touched">My recent issues</SelectItem> <SelectItem value="actionable">Needs action</SelectItem>
<SelectItem value="join_requests">Join requests</SelectItem> <SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="approvals">Approvals</SelectItem>
<SelectItem value="failed_runs">Failed runs</SelectItem>
<SelectItem value="alerts">Alerts</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)}
{showApprovalsCategory && ( </div>
<Select )}
value={allApprovalFilter}
onValueChange={(value) => setAllApprovalFilter(value as InboxApprovalFilter)}
>
<SelectTrigger className="h-8 w-[170px] text-xs">
<SelectValue placeholder="Approval status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All approval statuses</SelectItem>
<SelectItem value="actionable">Needs action</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
</SelectContent>
</Select>
)}
</div>
)}
</div>
{approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>} {approvalsError && <p className="text-sm text-destructive">{approvalsError.message}</p>}
{actionError && <p className="text-sm text-destructive">{actionError}</p>} {actionError && <p className="text-sm text-destructive">{actionError}</p>}
@ -1050,9 +1314,36 @@ export function Inbox() {
<> <>
{showSeparatorBefore("work_items") && <Separator />} {showSeparatorBefore("work_items") && <Separator />}
<div> <div>
<div className="overflow-hidden rounded-xl border border-border bg-card"> <div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
{workItemsToRender.map((item) => { {workItemsToRender.flatMap((item, index) => {
const isMineTab = tab === "mine"; const wrapItem = (key: string, isSelected: boolean, child: ReactNode) => (
<div
key={`sel-${key}`}
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(index)}
>
{child}
</div>
);
const todayCutoff = Date.now() - 24 * 60 * 60 * 1000;
const showTodayDivider =
index > 0 &&
item.timestamp > 0 &&
item.timestamp < todayCutoff &&
workItemsToRender[index - 1].timestamp >= todayCutoff;
const elements: ReactNode[] = [];
if (showTodayDivider) {
elements.push(
<div key="today-divider" className="flex items-center gap-3 px-4 my-2">
<div className="flex-1 border-t border-zinc-600" />
<span className="shrink-0 text-[11px] font-medium uppercase tracking-wider text-zinc-500">
Today
</span>
</div>,
);
}
const isSelected = selectedIndex === index;
if (item.kind === "approval") { if (item.kind === "approval") {
const approvalKey = `approval:${item.approval.id}`; const approvalKey = `approval:${item.approval.id}`;
@ -1061,13 +1352,14 @@ export function Inbox() {
<ApprovalInboxRow <ApprovalInboxRow
key={approvalKey} key={approvalKey}
approval={item.approval} approval={item.approval}
selected={isSelected}
requesterName={agentName(item.approval.requestedByAgentId)} requesterName={agentName(item.approval.requestedByAgentId)}
onApprove={() => approveMutation.mutate(item.approval.id)} onApprove={() => approveMutation.mutate(item.approval.id)}
onReject={() => rejectMutation.mutate(item.approval.id)} onReject={() => rejectMutation.mutate(item.approval.id)}
isPending={approveMutation.isPending || rejectMutation.isPending} isPending={approveMutation.isPending || rejectMutation.isPending}
unreadState={nonIssueUnreadState(approvalKey)} unreadState={nonIssueUnreadState(approvalKey)}
onMarkRead={() => handleMarkNonIssueRead(approvalKey)} onMarkRead={() => handleMarkNonIssueRead(approvalKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(approvalKey) : undefined} onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(approvalKey) : undefined}
archiveDisabled={isArchiving} archiveDisabled={isArchiving}
className={ className={
isArchiving isArchiving
@ -1076,15 +1368,17 @@ export function Inbox() {
} }
/> />
); );
return isMineTab ? ( elements.push(wrapItem(approvalKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive <SwipeToArchive
key={approvalKey} key={approvalKey}
selected={isSelected}
disabled={isArchiving} disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(approvalKey)} onArchive={() => handleArchiveNonIssue(approvalKey)}
> >
{row} {row}
</SwipeToArchive> </SwipeToArchive>
) : row; ) : row));
return elements;
} }
if (item.kind === "failed_run") { if (item.kind === "failed_run") {
@ -1094,6 +1388,7 @@ export function Inbox() {
<FailedRunInboxRow <FailedRunInboxRow
key={runKey} key={runKey}
run={item.run} run={item.run}
selected={isSelected}
issueById={issueById} issueById={issueById}
agentName={agentName(item.run.agentId)} agentName={agentName(item.run.agentId)}
issueLinkState={issueLinkState} issueLinkState={issueLinkState}
@ -1102,7 +1397,7 @@ export function Inbox() {
isRetrying={retryingRunIds.has(item.run.id)} isRetrying={retryingRunIds.has(item.run.id)}
unreadState={nonIssueUnreadState(runKey)} unreadState={nonIssueUnreadState(runKey)}
onMarkRead={() => handleMarkNonIssueRead(runKey)} onMarkRead={() => handleMarkNonIssueRead(runKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(runKey) : undefined} onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(runKey) : undefined}
archiveDisabled={isArchiving} archiveDisabled={isArchiving}
className={ className={
isArchiving isArchiving
@ -1111,15 +1406,17 @@ export function Inbox() {
} }
/> />
); );
return isMineTab ? ( elements.push(wrapItem(runKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive <SwipeToArchive
key={runKey} key={runKey}
selected={isSelected}
disabled={isArchiving} disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(runKey)} onArchive={() => handleArchiveNonIssue(runKey)}
> >
{row} {row}
</SwipeToArchive> </SwipeToArchive>
) : row; ) : row));
return elements;
} }
if (item.kind === "join_request") { if (item.kind === "join_request") {
@ -1129,12 +1426,13 @@ export function Inbox() {
<JoinRequestInboxRow <JoinRequestInboxRow
key={joinKey} key={joinKey}
joinRequest={item.joinRequest} joinRequest={item.joinRequest}
selected={isSelected}
onApprove={() => approveJoinMutation.mutate(item.joinRequest)} onApprove={() => approveJoinMutation.mutate(item.joinRequest)}
onReject={() => rejectJoinMutation.mutate(item.joinRequest)} onReject={() => rejectJoinMutation.mutate(item.joinRequest)}
isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending} isPending={approveJoinMutation.isPending || rejectJoinMutation.isPending}
unreadState={nonIssueUnreadState(joinKey)} unreadState={nonIssueUnreadState(joinKey)}
onMarkRead={() => handleMarkNonIssueRead(joinKey)} onMarkRead={() => handleMarkNonIssueRead(joinKey)}
onArchive={isMineTab ? () => handleArchiveNonIssue(joinKey) : undefined} onArchive={canArchiveFromTab ? () => handleArchiveNonIssue(joinKey) : undefined}
archiveDisabled={isArchiving} archiveDisabled={isArchiving}
className={ className={
isArchiving isArchiving
@ -1143,15 +1441,17 @@ export function Inbox() {
} }
/> />
); );
return isMineTab ? ( elements.push(wrapItem(joinKey, isSelected, canArchiveFromTab ? (
<SwipeToArchive <SwipeToArchive
key={joinKey} key={joinKey}
selected={isSelected}
disabled={isArchiving} disabled={isArchiving}
onArchive={() => handleArchiveNonIssue(joinKey)} onArchive={() => handleArchiveNonIssue(joinKey)}
> >
{row} {row}
</SwipeToArchive> </SwipeToArchive>
) : row; ) : row));
return elements;
} }
const issue = item.issue; const issue = item.issue;
@ -1163,32 +1463,19 @@ export function Inbox() {
key={`issue:${issue.id}`} key={`issue:${issue.id}`}
issue={issue} issue={issue}
issueLinkState={issueLinkState} issueLinkState={issueLinkState}
selected={isSelected}
className={ className={
isArchiving isArchiving
? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out" ? "pointer-events-none -translate-x-4 scale-[0.98] opacity-0 transition-all duration-200 ease-out"
: "transition-all duration-200 ease-out" : "transition-all duration-200 ease-out"
} }
desktopMetaLeading={( desktopMetaLeading={
<> <InboxIssueMetaLeading
<span className="hidden shrink-0 sm:inline-flex"> issue={issue}
<StatusIcon status={issue.status} /> selected={isSelected}
</span> isLive={liveIssueIds.has(issue.id)}
<span className="shrink-0 font-mono text-xs text-muted-foreground"> />
{issue.identifier ?? issue.id.slice(0, 8)} }
</span>
{liveIssueIds.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={ mobileMeta={
issue.lastExternalCommentAt issue.lastExternalCommentAt
? `commented ${timeAgo(issue.lastExternalCommentAt)}` ? `commented ${timeAgo(issue.lastExternalCommentAt)}`
@ -1199,7 +1486,7 @@ export function Inbox() {
} }
onMarkRead={() => markReadMutation.mutate(issue.id)} onMarkRead={() => markReadMutation.mutate(issue.id)}
onArchive={ onArchive={
isMineTab canArchiveFromTab
? () => archiveIssueMutation.mutate(issue.id) ? () => archiveIssueMutation.mutate(issue.id)
: undefined : undefined
} }
@ -1212,15 +1499,17 @@ export function Inbox() {
/> />
); );
return isMineTab ? ( elements.push(wrapItem(`issue:${issue.id}`, isSelected, canArchiveFromTab ? (
<SwipeToArchive <SwipeToArchive
key={`issue:${issue.id}`} key={`issue:${issue.id}`}
selected={isSelected}
disabled={isArchiving || archiveIssueMutation.isPending} disabled={isArchiving || archiveIssueMutation.isPending}
onArchive={() => archiveIssueMutation.mutate(issue.id)} onArchive={() => archiveIssueMutation.mutate(issue.id)}
> >
{row} {row}
</SwipeToArchive> </SwipeToArchive>
) : row; ) : row));
return elements;
})} })}
</div> </div>
</div> </div>

View file

@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
pi_local: "Pi (local)", pi_local: "Pi (local)",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)", cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process", process: "Process",
http: "HTTP", http: "HTTP",
}; };
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
function dateTime(value: string) { function dateTime(value: string) {
return new Date(value).toLocaleString(); return new Date(value).toLocaleString();

View file

@ -14,7 +14,16 @@ import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees"; import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb"; import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import {
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
isQueuedIssueComment,
mergeIssueComments,
upsertIssueComment,
type IssueCommentReassignment,
type OptimisticIssueComment,
} from "../lib/optimistic-issue-comments";
import { useProjectOrder } from "../hooks/useProjectOrder"; import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor"; import { InlineEditor } from "../components/InlineEditor";
@ -55,11 +64,15 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared"; import type { ActivityEvent } from "@paperclipai/shared";
import type { Agent, IssueAttachment } from "@paperclipai/shared"; import type { Agent, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
type CommentReassignment = { type CommentReassignment = IssueCommentReassignment;
assigneeAgentId: string | null; type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
assigneeUserId: string | null; runId?: string | null;
runAgentId?: string | null;
interruptedRunId?: string | null;
queueState?: "queued";
queueTargetRunId?: string | null;
}; };
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
@ -213,6 +226,7 @@ export function IssueDetail() {
}); });
const [attachmentError, setAttachmentError] = useState<string | null>(null); const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false); const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null); const lastMarkedReadIssueIdRef = useRef<string | null>(null);
@ -269,9 +283,17 @@ export function IssueDetail() {
}); });
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun; const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const runningIssueRun = useMemo(
() => (
activeRun?.status === "running"
? activeRun
: (liveRuns ?? []).find((run) => run.status === "running") ?? null
),
[activeRun, liveRuns],
);
const sourceBreadcrumb = useMemo( const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" }, () => readIssueDetailBreadcrumb(location.state, location.search) ?? { label: "Issues", href: "/issues" },
[location.state], [location.state, location.search],
); );
// Filter out runs already shown by the live widget to avoid duplication // Filter out runs already shown by the live widget to avoid duplication
@ -386,12 +408,23 @@ export function IssueDetail() {
); );
const suggestedAssigneeValue = useMemo( const suggestedAssigneeValue = useMemo(
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId), () =>
[issue, comments, currentUserId], suggestedCommentAssigneeValue(
issue ?? {},
mergeIssueComments(comments ?? [], optimisticComments),
currentUserId,
),
[issue, comments, optimisticComments, currentUserId],
); );
const commentsWithRunMeta = useMemo(() => { const threadComments = useMemo(
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>(); () => mergeIssueComments(comments ?? [], optimisticComments),
[comments, optimisticComments],
);
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const agentIdByRunId = new Map<string, string>(); const agentIdByRunId = new Map<string, string>();
for (const run of linkedRuns ?? []) { for (const run of linkedRuns ?? []) {
agentIdByRunId.set(run.runId, run.agentId); agentIdByRunId.set(run.runId, run.agentId);
@ -401,16 +434,44 @@ export function IssueDetail() {
const details = evt.details ?? {}; const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null; const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
if (!commentId || runMetaByCommentId.has(commentId)) continue; if (!commentId || runMetaByCommentId.has(commentId)) continue;
const interruptedRunId =
typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null;
runMetaByCommentId.set(commentId, { runMetaByCommentId.set(commentId, {
runId: evt.runId, runId: evt.runId,
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null, runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
interruptedRunId,
}); });
} }
return (comments ?? []).map((comment) => { return threadComments.map((comment) => {
const meta = runMetaByCommentId.get(comment.id); const meta = runMetaByCommentId.get(comment.id);
return meta ? { ...comment, ...meta } : comment; const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
if (
isQueuedIssueComment({
comment: nextComment,
activeRunStartedAt,
runId: meta?.runId ?? nextComment.runId ?? null,
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
})
) {
return {
...nextComment,
queueState: "queued" as const,
queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
};
}
return nextComment;
}); });
}, [activity, comments, linkedRuns]); }, [activity, threadComments, linkedRuns, runningIssueRun]);
const queuedComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"),
[commentsWithRunMeta],
);
const timelineComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
[commentsWithRunMeta],
);
const issueCostSummary = useMemo(() => { const issueCostSummary = useMemo(() => {
let input = 0; let input = 0;
@ -489,9 +550,67 @@ export function IssueDetail() {
}); });
const addComment = useMutation({ const addComment = useMutation({
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen), issuesApi.addComment(issueId!, body, reopen, interrupt),
onSuccess: () => { onMutate: async ({ body, reopen, interrupt }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const queuedComment = !interrupt && runningIssueRun;
const optimisticComment = issue
? createOptimisticIssueComment({
companyId: issue.companyId,
issueId: issue.id,
body,
authorUserId: currentUserId,
clientStatus: queuedComment ? "queued" : "pending",
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
})
: null;
if (optimisticComment) {
setOptimisticComments((current) => [...current, optimisticComment]);
}
if (previousIssue) {
queryClient.setQueryData(
queryKeys.issues.detail(issueId!),
applyOptimisticIssueCommentUpdate(previousIssue, { reopen }),
);
}
return {
optimisticCommentId: optimisticComment?.clientId ?? null,
previousIssue,
};
},
onSuccess: (comment, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
queryClient.setQueryData<IssueComment[]>(
queryKeys.issues.comments(issueId!),
(current) => upsertIssueComment(current, comment),
);
},
onError: (err, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
if (context?.previousIssue) {
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
}
pushToast({
title: "Comment failed",
body: err instanceof Error ? err.message : "Unable to post comment",
tone: "error",
});
},
onSettled: () => {
invalidateIssue(); invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
}, },
@ -501,10 +620,12 @@ export function IssueDetail() {
mutationFn: ({ mutationFn: ({
body, body,
reopen, reopen,
interrupt,
reassignment, reassignment,
}: { }: {
body: string; body: string;
reopen?: boolean; reopen?: boolean;
interrupt?: boolean;
reassignment: CommentReassignment; reassignment: CommentReassignment;
}) => }) =>
issuesApi.update(issueId!, { issuesApi.update(issueId!, {
@ -512,13 +633,96 @@ export function IssueDetail() {
assigneeAgentId: reassignment.assigneeAgentId, assigneeAgentId: reassignment.assigneeAgentId,
assigneeUserId: reassignment.assigneeUserId, assigneeUserId: reassignment.assigneeUserId,
...(reopen ? { status: "todo" } : {}), ...(reopen ? { status: "todo" } : {}),
...(interrupt ? { interrupt } : {}),
}), }),
onSuccess: () => { onMutate: async ({ body, reopen, reassignment, interrupt }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const queuedComment = !interrupt && runningIssueRun;
const optimisticComment = issue
? createOptimisticIssueComment({
companyId: issue.companyId,
issueId: issue.id,
body,
authorUserId: currentUserId,
clientStatus: queuedComment ? "queued" : "pending",
queueTargetRunId: queuedComment ? runningIssueRun.id : null,
})
: null;
if (optimisticComment) {
setOptimisticComments((current) => [...current, optimisticComment]);
}
if (previousIssue) {
queryClient.setQueryData(
queryKeys.issues.detail(issueId!),
applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }),
);
}
return {
optimisticCommentId: optimisticComment?.clientId ?? null,
previousIssue,
};
},
onSuccess: (result, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
const { comment, ...nextIssue } = result;
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
if (comment) {
queryClient.setQueryData<IssueComment[]>(
queryKeys.issues.comments(issueId!),
(current) => upsertIssueComment(current, comment),
);
}
},
onError: (err, _variables, context) => {
if (context?.optimisticCommentId) {
setOptimisticComments((current) =>
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
);
}
if (context?.previousIssue) {
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
}
pushToast({
title: "Comment failed",
body: err instanceof Error ? err.message : "Unable to post comment",
tone: "error",
});
},
onSettled: () => {
invalidateIssue(); invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
}, },
}); });
const interruptQueuedComment = useMutation({
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
onSuccess: () => {
invalidateIssue();
pushToast({
title: "Interrupt requested",
body: "The active run is stopping so queued comments can continue next.",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Interrupt failed",
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
tone: "error",
});
},
});
const uploadAttachment = useMutation({ const uploadAttachment = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected"); if (!selectedCompanyId) throw new Error("No company selected");
@ -581,9 +785,12 @@ export function IssueDetail() {
// Redirect to identifier-based URL if navigated via UUID // Redirect to identifier-based URL if navigated via UUID
useEffect(() => { useEffect(() => {
if (issue?.identifier && issueId !== issue.identifier) { if (issue?.identifier && issueId !== issue.identifier) {
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state }); navigate(createIssueDetailPath(issue.identifier, location.state, location.search), {
replace: true,
state: location.state,
});
} }
}, [issue, issueId, navigate, location.state]); }, [issue, issueId, navigate, location.state, location.search]);
useEffect(() => { useEffect(() => {
if (!issue?.id) return; if (!issue?.id) return;
@ -695,7 +902,7 @@ export function IssueDetail() {
<span key={ancestor.id} className="flex items-center gap-1"> <span key={ancestor.id} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />} {i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
<Link <Link
to={`/issues/${ancestor.identifier ?? ancestor.id}`} to={createIssueDetailPath(ancestor.identifier ?? ancestor.id, location.state, location.search)}
state={location.state} state={location.state}
className="hover:text-foreground transition-colors truncate max-w-[200px]" className="hover:text-foreground transition-colors truncate max-w-[200px]"
title={ancestor.title} title={ancestor.title}
@ -1025,7 +1232,8 @@ export function IssueDetail() {
<TabsContent value="comments"> <TabsContent value="comments">
<CommentThread <CommentThread
comments={commentsWithRunMeta} comments={timelineComments}
queuedComments={queuedComments}
linkedRuns={timelineRuns} linkedRuns={timelineRuns}
companyId={issue.companyId} companyId={issue.companyId}
projectId={issue.projectId} projectId={issue.projectId}
@ -1037,6 +1245,10 @@ export function IssueDetail() {
currentAssigneeValue={actualAssigneeValue} currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue} suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions} mentions={mentionOptions}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
onAdd={async (body, reopen, reassignment) => { onAdd={async (body, reopen, reassignment) => {
if (reassignment) { if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment }); await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
@ -1063,7 +1275,7 @@ export function IssueDetail() {
{childIssues.map((child) => ( {childIssues.map((child) => (
<Link <Link
key={child.id} key={child.id}
to={`/issues/${child.identifier ?? child.id}`} to={createIssueDetailPath(child.identifier ?? child.id, location.state, location.search)}
state={location.state} state={location.state}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors" className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
> >

View file

@ -70,6 +70,7 @@ export function Issues() {
createIssueDetailLocationState( createIssueDetailLocationState(
"Issues", "Issues",
`${location.pathname}${location.search}${location.hash}`, `${location.pathname}${location.search}${location.hash}`,
"issues",
), ),
[location.pathname, location.search, location.hash], [location.pathname, location.search, location.hash],
); );

View file

@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
"opencode_local", "opencode_local",
"pi_local", "pi_local",
"cursor", "cursor",
"hermes_local",
"openclaw_gateway", "openclaw_gateway",
]); ]);

View file

@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
gemini_local: "Gemini", gemini_local: "Gemini",
opencode_local: "OpenCode", opencode_local: "OpenCode",
cursor: "Cursor", cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway", openclaw_gateway: "OpenClaw Gateway",
process: "Process", process: "Process",
http: "HTTP", http: "HTTP",