feat(ui): add agent chat using the shared task surface
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
64c9edc29f
commit
ff2bca0d08
|
|
@ -160,3 +160,17 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**,
|
|||
|
||||
9. **Thin core, rich edges**
|
||||
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
|
||||
|
||||
### Experimental persistent agent conversations
|
||||
|
||||
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
|
|
|||
|
|
@ -1568,6 +1568,28 @@ Export/import behavior in V1:
|
|||
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
|
||||
- GitHub imports warn on unpinned refs instead of blocking
|
||||
|
||||
### Experimental task-backed agent chat (2026-09-10)
|
||||
|
||||
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
|
||||
|
||||
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue on first send/upload. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
|
||||
|
||||
The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history.
|
||||
|
||||
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
|
||||
|
||||
Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. Feature disablement prevents new turns and resets while retaining data and lifecycle protection; already-running turns may settle normally.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### User messages after native execution recovery stops
|
||||
|
||||
An authenticated user message can start a fresh native conversation turn once
|
||||
|
|
|
|||
12
doc/SPEC.md
12
doc/SPEC.md
|
|
@ -277,6 +277,8 @@ All agent communication flows through the **task system**.
|
|||
|
||||
There is no separate messaging or chat system. Tasks are the communication channel. This keeps all context attached to the work it relates to and creates a natural audit trail.
|
||||
|
||||
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
|
||||
|
||||
### Implications
|
||||
|
||||
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
|
||||
|
|
@ -549,6 +551,16 @@ Things Paperclip explicitly does **not** do:
|
|||
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
|
||||
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
### Paused task messages
|
||||
|
||||
A paused task takes over the composer with an amber notice and a Resume action.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
# Persistent agent chat, backed by tasks
|
||||
|
||||
Date: 2026-09-10
|
||||
Status: Implemented behind `enableAgentChat`; verification recorded in the implementation handoff.
|
||||
|
||||
## Contract
|
||||
|
||||
Each person has one persistent conversation with each agent in a company. A conversation is an ordinary issue with fixed `conversationAgentId` and `conversationUserId`, a matching agent assignee, and a unique company/agent/user identity. The authenticated board actor supplies the user identity (`local-board` in local trusted mode). Company task authorization still applies: these are separate conversations, not private messages.
|
||||
|
||||
Opening an unused conversation performs a read. First send or upload resolves its backing issue atomically through `POST /api/companies/:companyId/chats/:agentRef`. `GET` on the same path returns the existing issue or null. Comments, documents, files, interactions, runs, and subscriptions use the existing task APIs. User chat comments require a stable `clientRequestId`; retries return the same comment. Comment rows also provide a durable delivery outbox, serialized across servers before admission to the normal execution queue.
|
||||
|
||||
Idle conversations are `in_review` with server-owned `conversationState: waiting`. A new message makes the conversation active. A successful run parks it only after a durable agent response, with no later pending message. Failed or unanswered turns retain ordinary error handling. Finalizers, assignment recovery, liveness classification, timer eligibility, and work counts distinguish conversations from execution tasks. Completion or reassignment cannot terminate or transfer the container. Child completion does not wake the conversation.
|
||||
|
||||
## Session boundaries
|
||||
|
||||
Standalone `/new` is an ordinary queued user comment. The shared composer offers it as a slash command. It executes at a turn boundary without invoking the provider, increments `conversationSessionGeneration`, and records `conversationBoundaryCommentId` plus the generation on the command comment. Processing a retried command is idempotent. Only the matching agent/task provider session is deleted; agent-wide state and other task sessions are untouched.
|
||||
|
||||
A board-authored `/new` also releases pause holds rooted at the chat without waking the stopped turn. Dispatch admits the verified reset even when the previous turn has a no-replay recovery disposition; after the boundary, that old disposition remains auditable but does not block a fresh session. Pending clarification questions from the old session expire, including questions configured to survive ordinary comments. The reset run adds no empty-response notice.
|
||||
|
||||
Provider-session writes and run-authored replies check the generation. Cancelled conversation runs cannot issue mutating API calls, post late replies, or restore provider sessions. Fresh prompt replay is bounded to nondeleted comments after the boundary and before the current wake comment, with source-trust sanitization. Automatic task continuation summaries are omitted for conversations. History, artifacts, plans, and linked tasks retain their IDs and remain available for explicit inspection. The shared transcript renders processed command comments as session dividers.
|
||||
|
||||
## Agent policy
|
||||
|
||||
`server/src/services/agent-conversations.ts` owns the chat directive. The task prompt includes it on initial turns, retries, resumed turns, and fresh sessions. It asks the agent to clarify material gaps, then create and assign ordinary project tasks with outcomes, context, copied plans, and acceptance criteria before claiming they exist. It explicitly overrides ordinary completion and accepted-plan execution instructions for the container. Ask mode stays non-mutating; plan mode supports clarification and planning. Normal tools, approvals, budgets, assignment, and execution policies continue to apply.
|
||||
|
||||
## Shared production composition
|
||||
|
||||
`TaskDetailSurface` in `IssueDetail.tsx` is the shared controller and surface. `AgentChat.tsx` resolves the canonical task and provides an ephemeral view model before the first write. It does not implement a second transcript, composer, file panel, or run controller. The chat presentation hides task metadata and the seeded description bubble, uses task breadcrumb typography with agent avatar/name and a configuration-page gear, and defaults the existing task side panel to artifacts/plans rather than Properties.
|
||||
|
||||
Company-prefixed `chats/:agentRef` routes open the current person's conversation. Direct task URLs remain supported. The existing Agents roster provides a Chat action. The shared sidebar lists starred agents alphabetically, then four recent unstarred conversations, without a divider. Stars appear on hover or keyboard focus. Existing resource memberships store stars; company/user-scoped recent-navigation storage records conversation visits only. The gear goes to agent runtime configuration; See all agents goes to `/agents/all`.
|
||||
|
||||
## Rollout
|
||||
|
||||
`enableAgentChat` defaults to false in the shared feature catalog, validator, server settings, and Experimental settings UI. Navigation, resolution, new messages, and reset commands are gated. Turning the flag off preserves data, allows already-running turns to settle, and prevents new chat execution. Lifecycle protection is independent of flag state; task links remain readable under normal authorization.
|
||||
|
||||
## Verification
|
||||
|
||||
Database and route tests cover concurrent canonical creation, independent users with ordinary company visibility, client retry identity, cross-company denial, local identity, ordered concurrent delivery, separate queued resets, generation fences, replay boundaries, idle recovery classification, disabled admission, and child wake suppression. Shared composer/sidebar/settings tests and Storybook fixtures cover the production composition. Storybook scenarios include first conversation, returning, working, paused, failed send, long history, session boundary, disabled feature, light theme, and ordinary task comparison.
|
||||
|
||||
Required handoff checks: targeted tests; token gates; Storybook build; repository typecheck, tests, and build; browser checks of first send, session divider, stars, switching, drafts, configuration, roster, and disabled states. Fixture navigation is not a claim of a live provider evaluation: task creation quality remains prompt-guided and should be observed during the experimental rollout.
|
||||
|
||||
### Implementation verification — 2026-09-10
|
||||
|
||||
- Repository typecheck (`pnpm -r typecheck`), production build (`pnpm build`), token gates, and Storybook build passed.
|
||||
- Final UI suite: 563 files, 5,622 tests passed. The shared controller/live-update regression pass covers first send and upload, preservation of agent routes, read-only unused conversations, personal live-update resolution, and durable session-divider refresh.
|
||||
- General server lane: 8,345 passed and 38 skipped initially; the four failures (a stale module loaded during editing and three socket disconnects) passed in a fresh 68-test rerun. The conversation suite also executes a real process adapter: two ordinary turns invoke it twice, `/new` invokes it zero times, and each answered turn returns to idle.
|
||||
- All 144 serialized route suites were exercised. The skill-route socket failure and queued-comment fixture cleanup failures passed in a 70-test rerun. Queue test cleanup now clears its full company-scoped foreign-key closure rather than ignoring failed deletes.
|
||||
- Shared, database, CLI, adapter, skills-catalog, and plugin project suites passed. The CLI migration test exposed and verified the cloned-database constraint upgrade fix. Adapter suites that exceeded the default five-second timeout passed with capped workers and a 30-second test timeout. Tests ran against isolated temporary homes and databases; repository-standard unsupported integration cases remained skipped.
|
||||
- Browser checks used the production composition with fixture APIs: first send, retry and draft retention, agent switching, stars, configuration/roster navigation, linked subtasks, paused-agent controls, disabled navigation, long history, and `/new` preserving earlier messages and plans. Live-update unit tests cover the socket/cache behavior independently of Storybook fixtures. No live-model task-handoff quality evaluation was performed.
|
||||
|
||||
The broad `pnpm test:run` attempt was followed by isolated group/file reruns for the failures above; this is not a claim that the initial monolithic command exited successfully. The experiment remains off by default.
|
||||
|
||||
## September 11 reset regression verification
|
||||
|
||||
The initial live demo checked an idle reset but missed Stop followed by `/new`. In the reported Claude run, dispatch cancelled both the reset and follow-up before reset processing; the provider generation stayed at zero, and the cancelled old turn posted a late reply. Regression coverage now includes pause plus a prior no-replay recovery disposition, reset and immediate follow-up queue order, cancelled-run write rejection, expiring persistent clarification questions, and suppressing empty reset-run transcript notices.
|
||||
|
||||
Live Codex and Claude checks confirmed fresh context after pause → `/new` → follow-up. An additional Claude check stopped an actively streaming turn containing a unique code word, reset, and asked for that word without history inspection. Claude reported it was absent; the chat returned to waiting.
|
||||
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
|
||||
|
||||
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
|
||||
|
||||
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
|
||||
|
||||
|
||||
### Project handoff verification (September 11)
|
||||
|
||||
The real-server tool tests cover concurrent project retries, task/plan atomic creation, ordinary child delegation, import/reparenting rejection under conversations, mode restrictions, cancellation, repository URL normalization, and committed project cards. The ordinary task review-path guard now exempts conversations; the server owns their waiting state after a successful reply. The chat directive explicitly tells agents to reply and end their turn without inventing a reviewer or changing status.
|
||||
|
||||
Five focused Codex live evals passed: existing-project reuse, new project/task handoff, multiple repository URLs, plan-only drafting, and authorized repository discovery. Four provider-free contract evals passed for retries, missing access, Ask-mode denial, and persisted handoff plans. The companion harness has 30 passing tests. The qualified Claude eval profile could not start on macOS without its explicit eval credential (it additionally requires Linux x64); it was not bypassed.
|
||||
|
||||
A separate local Claude agent drafted and revised a chat plan, created Garden Club Demo through the dedicated project tool, and created normal assigned task AGE-7 with its initial plan. The plan was persisted at 16:47:51.426 UTC before execution started at 16:47:51.492 UTC; the task completed with an output document and the original chat plan remained. Local Codex created Repository URL Demo with two URLs absent from its catalog; both appeared on the inline card and project configuration. The card persisted across reload and `/new`. Light and dark production-composition stories were inspected in the browser.
|
||||
|
||||
The broad general-server run reported 8,362 passing tests and two failures from pre-fix modules cached before the review guard and explicit-workspace card changes. The fresh current-source API run passed all 21 tests across four files, including both regressions. Remaining repository groups are verified separately so the initial monolithic exit is not represented as a clean pass.
|
||||
|
||||
The full UI lane passed 5,626 tests and the CLI passed 484. The shared and skills-catalog projects passed. The remaining database/adapter/plugin group passed 2,365 tests; a migration startup failure passed alone (1 test), after reducing workers to avoid embedded-Postgres contention. Existing unsupported integration tests remained skipped.
|
||||
|
||||
Both serialized server shards are now verified: all 144 suites passed across their final runs/resumed segments. An outdated project-route mock and the new MCP transport's missing OpenAPI inventory entry were corrected; embedded-Postgres startup failures passed in isolated retries. The API catalog now includes the task-run-only MCP transport and points project discovery/creation to their dedicated tools; its focused suite passed 824 tests. The catalog census has 792 operations (555 authored REST contract cases).
|
||||
|
||||
Repository-wide typecheck and build, Storybook build, and token gates passed. The final API metadata change also passed server typecheck/build. Two follow-up Codex live cases passed with the final directive, and all 11 retained deterministic/live artifacts passed the stronger persisted-state scoring, including detection of unintended tasks created through API fallback. These results do not turn the earlier failed monolithic test command into a clean run.
|
||||
|
||||
|
||||
### 2026-09-11: E2E regression coverage
|
||||
|
||||
Persistent conversations now have dedicated `tests/e2e/agent-chat.spec.ts`
|
||||
coverage using a deterministic process adapter against a disposable real server.
|
||||
The authenticated suite additionally checks separate canonical chats, personal
|
||||
stars/recency, shared company visibility, and cross-company denial for two people.
|
||||
The runner catalog registers `agent-chat`: six scenarios on four local
|
||||
Codex/Claude profiles (24 paid cells). See `tests/runner-e2e/README.md` for launch
|
||||
commands, credential preflight, evidence, and reset/child-run accounting.
|
||||
|
||||
Browser testing identified a company-cache shape mismatch in chat live updates
|
||||
and a dropped reset marker in compact run summaries. Preserve the shared cache
|
||||
contract and `conversationReset` summary field so messages refresh live and reset
|
||||
boundaries do not render empty model-completion notices.
|
||||
|
||||
Local acceptance on 2026-09-11: all 20 deterministic chat scenarios and two
|
||||
existing repository browser scenarios passed against a fresh test instance.
|
||||
The new authenticated two-person scenario passed independently. Runner fixture
|
||||
checks passed (121 tests), and the live-update/run-summary regression checks
|
||||
passed (46 tests). Repository typecheck, build, Storybook build, and token gates
|
||||
passed. Paid Codex and Claude smoke attempts failed credential preflight because
|
||||
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY` were unavailable; the 24-cell matrix is
|
||||
registered but has no claimed paid passing coverage from this run.
|
||||
|
||||
|
||||
### 2026-09-11: Paid runner regression fixes
|
||||
|
||||
The first GitHub campaign exercised all 24 cells and exposed provider-session,
|
||||
queue/lifecycle, plan-review, and shared-feed issues. Follow-up work uses focused
|
||||
provider-free regressions first, then individual paid cells on disposable
|
||||
instances; the running demo remains untouched.
|
||||
|
||||
Claude session serialization now retains its MCP server identity. Conversation
|
||||
containers ignore dependency and child-completion wakes, while pending questions
|
||||
and plan reviews count as durable replies and settle the conversation to waiting.
|
||||
Rejected plan feedback is included in both full and resumed prompt assembly;
|
||||
acceptance resolves the implicit current-task target and hands off the selected
|
||||
plan revision before execution begins.
|
||||
|
||||
Native provider handling preserves FIFO events and terminal schema, projects
|
||||
committed normal replies into chat, and verifies ownership when a restored ACPX
|
||||
session lazily launches its provider during model selection. Linux Codex preflight
|
||||
uses an exact executable AppArmor profile and a provider-free sandbox probe. The
|
||||
focused GitHub campaign `34638268637` passed native Codex continuity/restart and
|
||||
fresh-session reset on both selected cells.
|
||||
|
||||
The shared project card hydrates repository links from the authorized project
|
||||
record while retaining its original durable creation receipt. Regression coverage
|
||||
checks a second repository arriving after creation, reload, and `/new`. Handoff
|
||||
fixtures check committed repository workspaces and actual output documents rather
|
||||
than assuming URL registration adds an entry to the external connection catalog
|
||||
or requiring an unspecified output document key. Failure classification avoids
|
||||
paid retries for explicit non-retryable provider-session failures.
|
||||
|
||||
All 20 deterministic chat browser scenarios and Storybook build passed after
|
||||
these fixes. Focused live checks additionally passed legacy Codex project reuse
|
||||
and repository handoff, legacy Claude plan revision/acceptance/handoff, and native
|
||||
Claude planning, Stop/reset/resume, fresh sessions, and multiple repositories.
|
||||
Final campaign results and broad verification are recorded below when complete.
|
||||
|
||||
The next full campaign (`34640536416`) reached 18/24 passing cells and identified
|
||||
three additional issues. Execution prompts now include the task's persisted plan
|
||||
and selected revision on both fresh and resumed runs; a plan handed off without a
|
||||
description therefore still reaches its executor. Native durable redaction keeps
|
||||
explicit literal/exact acceptance identifiers while continuing to redact actual
|
||||
credential-shaped values. Recovery for an older conversation generation or an
|
||||
already answered turn cannot block a reset or healthy idle chat. Regression tests
|
||||
also preserve recovery for current unanswered turns and unprepared failures.
|
||||
|
||||
Fixture assertions now accept concrete clarification requests without requiring a
|
||||
question mark. They check the approved revision and final execution output rather
|
||||
than rejecting an old draft quoted in plan revision history. Restart verification
|
||||
opens the canonical chat route after reconnecting, preserving the continuity and
|
||||
no-unsolicited-run checks. Stable inconsistent idle states fail promptly instead
|
||||
of waiting through a long timeout and hiding a product race behind a paid retry.
|
||||
Focused native Claude project reuse and multiple-repository handoffs, and legacy
|
||||
Claude multiple-repository handoff, passed on their first attempts with these fixes.
|
||||
|
||||
The focused legacy Claude Stop/reset/resume regression also passed on its first
|
||||
attempt. Latest repository-wide typecheck, build, and token gates passed. Final
|
||||
runner fixture checks passed 151 tests; fresh chat/prompt/recovery checks passed
|
||||
209 tests, and the native session executor file passed 207 tests. Broad local
|
||||
verification is recorded as resumed groups rather than a clean monolithic run:
|
||||
the original command encountered source edits during execution, generated-evidence
|
||||
scanner input, and cold-import/process-startup timeouts under concurrent load.
|
||||
The guidance scanner now excludes only generated runner evidence and has a
|
||||
regression proving authored runner guidance remains scanned. Focused UI, database,
|
||||
publication, and canonical-path CLI reruns passed without product changes.
|
||||
|
||||
Broader adapter verification exposed OpenCode test fixtures reading the developer's
|
||||
real configuration directory. Those fixtures now allocate and restore isolated
|
||||
XDG configuration directories; all 44 source tests and package typecheck pass.
|
||||
The remaining workspace projects were run even after earlier groups stopped at a
|
||||
failure, and the original failure logs remain available alongside focused reruns.
|
||||
|
||||
Campaign `34642700703` passed 19/24 cells. Its remaining failures were traced to
|
||||
one clarification-oracle phrasing, revision-write guidance, runner teardown after
|
||||
a successful restart, and native mutation content passing through diagnostic
|
||||
redaction. The clarification fixture now also recognizes substantive requests
|
||||
for a brief or details. Revision instructions and HTTP conflict errors explicitly
|
||||
map the GET `latestRevisionId` to PUT `baseRevisionId`; a live Codex
|
||||
plan/revise/accept/handoff run passed on its first attempt with that fix.
|
||||
|
||||
Playwright now gives the restart supervisor a bounded SIGTERM shutdown so it can
|
||||
reap children and close log streams. A real zero-provider Playwright regression
|
||||
verifies restart, child process exit, and port closure; cleanup failure still
|
||||
fails the campaign. Native schema-declared task/project/document prose retains
|
||||
its complete contents while credentials and diagnostic data remain scrubbed.
|
||||
Regression coverage includes long plans beyond the diagnostic preview limit.
|
||||
The macOS fake Anthropic service now clears inherited nonblocking socket mode
|
||||
before its bounded request read; all 271 Rust library tests passed afterward.
|
||||
|
||||
All 144 serialized server suites have passing coverage across the resumed shards
|
||||
and focused reruns. Three route fixtures moved cold module imports into bounded
|
||||
setup hooks, preserving their HTTP assertion timeouts; the final affected files
|
||||
passed 119 tests. The completed workspace groups likewise have passing focused
|
||||
reruns for every observed failure. These results are recorded alongside, rather
|
||||
than replacing, the earlier failed monolithic invocation.
|
||||
|
||||
The ACPX sidecar decoder was an additional execution boundary: it applied generic
|
||||
diagnostic redaction before the native semantic-input stage. It now uses the same
|
||||
schema-declared prose policy at decode. The regression feeds a real
|
||||
`runtime.tool_called` event through decoding, pending-call state, and semantic
|
||||
projection, checking complete long-plan contents, protected credentials, unknown
|
||||
operation handling, and matching content digests. The decoder/state checks passed
|
||||
22 tests and durable-state checks passed 30 tests before the next paid campaign.
|
||||
|
||||
The final local native Claude repository handoff preserved the exact previously
|
||||
corrupted task description, plan, and execution output. Its product assertions
|
||||
passed on the first attempt; post-run secret scanning then exposed PostgreSQL
|
||||
removing `instances/<id>/db/postmaster.pid` after directory enumeration. Only
|
||||
ENOENT for that exact transient path is now tolerated. Existing PID contents,
|
||||
other scan errors, mandatory evidence, and process/lease cleanup remain enforced.
|
||||
All 157 runner fixture tests and final repository typecheck/build passed.
|
||||
Campaign `34645293835` tests the complete set of fixes.
|
||||
|
||||
Campaign `34645293835` passed 20/24 cells. Two failures exposed narrow lifecycle
|
||||
races: a successful native chat turn could be mistaken for productive unfinished
|
||||
work before response publication, and an agent comment deferred behind an active
|
||||
execution could wake its assignee after that execution completed. Recovery now
|
||||
leaves the first case to the conversation finalizer; queue promotion cancels the
|
||||
stale terminal-task continuation while retaining human reopening and notifications
|
||||
to other agents. The recovery regression fails with the guard removed and passes
|
||||
with it restored; all 20 comment-wake batching tests and server typecheck pass.
|
||||
|
||||
The other failures distinguish requested approval from ordinary draft planning,
|
||||
and a persisted Paperclip document from a workspace file. The chat directive now
|
||||
explains how to create a revision-bound approval card when explicitly requested,
|
||||
including after a revision. Paid fixtures name the requested Paperclip document
|
||||
explicitly while retaining strict checks of approvals, transferred plans, and
|
||||
persisted execution output.
|
||||
|
||||
Native reconciliation also preserves assessment lineage within its owning run
|
||||
when the task's previous status decision belongs to a different run. Decision
|
||||
lineage still spans runs; the database ownership constraint remains unchanged.
|
||||
The regression reproduces the original foreign-key failure without the fix and
|
||||
passes for absent, same-run, and different-run predecessors with it. The next
|
||||
24-cell campaign is `34646672139`, pinned to `3556fa25f`.
|
||||
|
||||
Campaign `34646672139` passed 23/24 cells: all native cases and all legacy Claude
|
||||
cases passed. The remaining legacy Codex plan-revision failure exposed an adapter
|
||||
prompt omission. Its resume delta discarded the server's task-context Markdown,
|
||||
including both the chat directive and document-concurrency guidance. Codex now
|
||||
selects the same full/compact task-context Markdown as Claude on initial and
|
||||
resumed sessions. Both adapters suppress generic task-completion and child-task
|
||||
planning directives in chat, leaving the central chat policy authoritative.
|
||||
The approval and output assertions remain unchanged.
|
||||
|
||||
Fresh chat prompts use a small conversation-safe default template that retains
|
||||
connection guidance, permissions, budgets, cancellation, and mutation honesty.
|
||||
Explicit custom agent templates remain intact. Native execution and continuation
|
||||
prompts also carry the conversation flag so shared wake rendering cannot reinsert
|
||||
ordinary completion/subtask instructions. The integration regression inspects
|
||||
both fake-CLI stdin and the recorded adapter invocation with the production chat
|
||||
directive; removing the task-context section reproduces the failure. Shared prompt
|
||||
checks (102), actual Codex prompt cases (3), native resume checks (11), affected
|
||||
package typechecks, and server typecheck pass. Campaign `34648511170` tests all
|
||||
24 cells on `abacbdfd2`.
|
||||
|
||||
The complete Codex/Claude execution regression files passed 46 tests. Final
|
||||
repository-wide typecheck and build also passed on `abacbdfd2`, after all prompt
|
||||
changes.
|
||||
|
||||
Final paid verification: campaign `34648511170` passed **24/24** chat cases on
|
||||
`abacbdfd2`: legacy Codex 6/6, legacy Claude 6/6, native Codex 6/6, and native
|
||||
ACPX Claude 6/6. All cells completed by 21:34 UTC on September 11, within the
|
||||
requested three-hour repair window. No acceptance assertions were disabled.
|
||||
|
||||
- [Exact campaign results](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/summary.md)
|
||||
- [GitHub run and retained evidence](https://github.com/paperclipai/paperclip/actions/runs/34648511170)
|
||||
|
||||
The [HTML dashboard](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-34648511170-1/index.html?report=agent-chat#suite-agent-chat)
|
||||
was repaired from retained evidence after its older trusted catalog omitted the
|
||||
branch-only suite. It now includes the chat suite and 32 screenshots, including
|
||||
eight draft/revised plan captures recovered from their original Playwright
|
||||
attachments. No paid cells were rerun; result records, tested SHA, timestamps,
|
||||
usage, billing, attempts, and cleanup outcomes remain unchanged.
|
||||
|
||||
Reporting now discovers validated display-only entries for unknown selected
|
||||
execution IDs, and publication rejects missing declared screenshots. The exact
|
||||
chat plan filenames are included in packaged evidence. All 165 runner unit tests
|
||||
and runner TypeScript checks passed. Browser verification covered suite
|
||||
filtering, restored plan images, and gallery navigation. This explicitly
|
||||
authorized repair replaces only this campaign's report objects; normal
|
||||
immutable-publication protections remain unchanged.
|
||||
|
||||
The published summary and normalized results were verified after publication:
|
||||
exactly 24 unique expected cells, all passed on attempt 1, all cleanup checks
|
||||
passed, all evidence valid with no evidence errors, and every result bound to
|
||||
`abacbdfd2f660709ec37312cdb758284c8399d04`. The public report returned HTTP 200.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,28 @@
|
|||
// Upstream GitHub simulation only. Paperclip's discovery, secret resolution,
|
||||
// responsible-user authorization, and project creation all remain real.
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const url = new URL(
|
||||
typeof input === "string" || input instanceof URL ? input : input.url,
|
||||
);
|
||||
const headers = new Headers(
|
||||
init?.headers ?? (input instanceof Request ? input.headers : undefined),
|
||||
);
|
||||
if (
|
||||
url.hostname === "api.github.com" &&
|
||||
headers.get("authorization") === "Bearer paperclip-e2e-repository-fixture"
|
||||
) {
|
||||
if (url.pathname !== "/user/repos")
|
||||
return Response.json(
|
||||
{ error: "Unsupported fixture GitHub request" },
|
||||
{ status: 422 },
|
||||
);
|
||||
return Response.json([
|
||||
{ id: 101, full_name: "chat-fixture/frontend", private: false },
|
||||
{ id: 102, full_name: "chat-fixture/backend", private: false },
|
||||
]);
|
||||
}
|
||||
return realFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
// Deterministic provider: all effects use the real run-authenticated APIs/MCP transport.
|
||||
// No DB writes, mocked Paperclip responses, provider calls, or outside workspaces.
|
||||
const base = process.env.PAPERCLIP_API_URL;
|
||||
const headers = {
|
||||
Authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
async function api(path, method = "GET", body) {
|
||||
const response = await fetch(`${base}/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`${method} ${path}: ${response.status} ${JSON.stringify(data)}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
const run = await api(`/heartbeat-runs/${process.env.PAPERCLIP_RUN_ID}`);
|
||||
const ctx = run.contextSnapshot;
|
||||
const task = await api(`/issues/${ctx.issueId}`);
|
||||
const comment = async (body) =>
|
||||
api(`/issues/${task.id}/comments`, "POST", { body });
|
||||
if (!task.conversationAgentId) {
|
||||
const plan = await api(`/issues/${task.id}/documents/plan`);
|
||||
await api(`/issues/${task.id}/documents/output`, "PUT", {
|
||||
title: "Output",
|
||||
format: "markdown",
|
||||
body: `Execution received plan: ${plan.body}`,
|
||||
});
|
||||
await api(`/issues/${task.id}`, "PATCH", {
|
||||
status: "done",
|
||||
comment: "Execution finished with its initial plan.",
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
const comments = await api(`/issues/${task.id}/comments?order=asc`);
|
||||
const current =
|
||||
comments.find((c) => c.id === ctx.wakeCommentId) ??
|
||||
comments.filter((c) => c.authorUserId).at(-1);
|
||||
let command;
|
||||
try {
|
||||
command = JSON.parse(
|
||||
current.body.startsWith("fixture:")
|
||||
? Buffer.from(current.body.slice(8), "base64url").toString()
|
||||
: current.body,
|
||||
);
|
||||
} catch {
|
||||
command = { action: "reply", text: current.body };
|
||||
}
|
||||
if (
|
||||
ctx.interactionKind === "request_confirmation" &&
|
||||
ctx.interactionStatus === "accepted"
|
||||
) {
|
||||
const plan = await api(`/issues/${task.id}/documents/plan`);
|
||||
command = {
|
||||
action: "handoff",
|
||||
plan: plan.body,
|
||||
name: "Approved plan project",
|
||||
key: ctx.interactionId,
|
||||
};
|
||||
}
|
||||
if (ctx.interactionKind === "ask_user_questions")
|
||||
command = { action: "reply", text: "Clarification received." };
|
||||
const writePlan = async (body) => {
|
||||
const documents = await api(`/issues/${task.id}/documents`);
|
||||
const previous = documents.find((doc) => doc.key === "plan");
|
||||
return api(`/issues/${task.id}/documents/plan`, "PUT", {
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: previous?.latestRevisionId,
|
||||
});
|
||||
};
|
||||
const mcp = async (name, args) => {
|
||||
const result = await api("/mcp/project-tools", "POST", {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name, arguments: args },
|
||||
});
|
||||
if (result.result?.isError || result.error)
|
||||
throw new Error(JSON.stringify(result));
|
||||
return result.result.structuredContent;
|
||||
};
|
||||
console.log("Deterministic chat provider received a turn");
|
||||
if (command.action === "hold") {
|
||||
await comment("Provider is streaming and ready to stop.");
|
||||
// Keep a real provider process alive so Stop exercises cancellation and tree holds.
|
||||
setInterval(() => console.log("Streaming discussion"), 250);
|
||||
} else if (command.action === "delayed") {
|
||||
await comment("Turn started before feature disable.");
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
await comment("Active turn settled after feature disable.");
|
||||
} else if (command.action === "project" || command.action === "handoff") {
|
||||
try {
|
||||
const args = {
|
||||
name: command.name ?? "Fixture project",
|
||||
repositoryUrls: command.urls,
|
||||
repositoryIds: command.ids,
|
||||
workspace: command.workspace,
|
||||
idempotencyKey: command.key ?? current.id,
|
||||
};
|
||||
const project = command.projectId
|
||||
? await api(`/projects/${command.projectId}`)
|
||||
: command.direct
|
||||
? await api(`/companies/${task.companyId}/projects`, "POST", args)
|
||||
: await mcp("create_project", args);
|
||||
const retry = command.projectId
|
||||
? project
|
||||
: await mcp("create_project", args);
|
||||
if (retry.id !== project.id)
|
||||
throw new Error("Project retry created a duplicate");
|
||||
if (command.action === "handoff") {
|
||||
const plan = command.plan ?? "# Plan\n\nWrite the welcome note.";
|
||||
if (!ctx.interactionId) await writePlan(plan);
|
||||
const tasks = [];
|
||||
for (let index = 0; index < (command.split ? 2 : 1); index++) {
|
||||
const input = {
|
||||
title: `Execution ${index + 1}`,
|
||||
projectId: project.id,
|
||||
initialPlan: `${plan}\nPart ${index + 1}`,
|
||||
idempotencyKey: `${current.id}-${index}`,
|
||||
};
|
||||
const child = await mcp("create_task", input);
|
||||
const again = await mcp("create_task", input);
|
||||
if (again.id !== child.id)
|
||||
throw new Error("Task retry created a duplicate");
|
||||
tasks.push(`[${child.identifier}](/issues/${child.id})`);
|
||||
}
|
||||
await comment(`Handed off: ${tasks.join(", ")}`);
|
||||
} else await comment(`Project registered: ${project.name}`);
|
||||
} catch (error) {
|
||||
await comment(`Expected tool result: ${error.message}`);
|
||||
}
|
||||
} else if (command.action === "plan") {
|
||||
const plan = await writePlan(command.text);
|
||||
if (command.approval)
|
||||
await api(`/issues/${task.id}/interactions`, "POST", {
|
||||
kind: "request_confirmation",
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Hand this plan off to an assigned project task?",
|
||||
acceptLabel: "Approve handoff",
|
||||
rejectLabel: "Revise",
|
||||
rejectRequiresReason: true,
|
||||
target: {
|
||||
type: "issue_document",
|
||||
key: "plan",
|
||||
revisionId: plan.latestRevisionId,
|
||||
revisionNumber: plan.latestRevisionNumber,
|
||||
},
|
||||
},
|
||||
});
|
||||
await comment("The draft plan is ready for discussion.");
|
||||
} else if (command.action === "question") {
|
||||
await api(`/issues/${task.id}/interactions`, "POST", {
|
||||
kind: "ask_user_questions",
|
||||
idempotencyKey: current.id,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
questions: [
|
||||
{
|
||||
id: "audience",
|
||||
prompt: "Who is the welcome note for?",
|
||||
selectionMode: "single",
|
||||
required: true,
|
||||
options: [
|
||||
{ id: "garden", label: "Garden club" },
|
||||
{ id: "book", label: "Book club" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await comment("Please choose an audience.");
|
||||
} else if (command.action === "history") {
|
||||
for (let index = 0; index < 65; index++)
|
||||
await comment(`History message ${String(index).padStart(2, "0")}`);
|
||||
} else {
|
||||
await comment(
|
||||
`Reply generation ${ctx.conversationSessionGeneration}: ${command.text}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -324,3 +324,62 @@ test.describe("Multi-user: authenticated mode", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("agent chats keep personal identity and ordinary company visibility", async ({ browser, page }) => {
|
||||
test.setTimeout(120_000);
|
||||
expect((await (await page.request.get(`${BASE}/api/health`)).json()).deploymentMode).toBe("authenticated");
|
||||
await signUp(page, { ...ownerUser, email: `chat-${ownerUser.email}` });
|
||||
const bootstrapToken = new URL(createBootstrapInvite()).pathname.split("/").at(-1);
|
||||
expect((await sessionJsonRequest(page, `${BASE}/api/invites/${bootstrapToken}/accept`, { method: "POST", data: { requestType: "human" } })).ok).toBe(true);
|
||||
const company = await createCompanyForSession(page, `Chat identity ${runId}`);
|
||||
const companyPrefix = company.issuePrefix ?? company.id;
|
||||
const invite = await sessionJsonRequest<{ inviteUrl: string }>(page, `${BASE}/api/companies/${company.id}/invites`, { method: "POST", data: { allowedJoinTypes: "human", humanRole: "operator" } });
|
||||
expect(invite.ok).toBe(true);
|
||||
const invited = await newPage(browser);
|
||||
try {
|
||||
await signUp(invited.page, { ...invitedUser, email: `chat-${invitedUser.email}` });
|
||||
const inviteToken = new URL(invite.json!.inviteUrl, BASE).pathname.split("/").at(-1);
|
||||
const joined = await sessionJsonRequest(invited.page, `${BASE}/api/invites/${inviteToken}/accept`, { method: "POST", data: { requestType: "human" } });
|
||||
expect(joined.ok).toBe(true);
|
||||
// Persistent chats are personal identities, not private messaging.
|
||||
const originalFlags = await sessionJsonRequest<Record<string, boolean>>(page, `${BASE}/api/instance/settings/experimental`);
|
||||
const enable = await sessionJsonRequest(page, `${BASE}/api/instance/settings/experimental`, { method: "PATCH", data: { enableAgentChat: true } });
|
||||
expect(enable.ok).toBe(true);
|
||||
try {
|
||||
const createdAgent = await sessionJsonRequest<{ id: string }>(page, `${BASE}/api/companies/${company.id}/agents`, { method: "POST", data: {
|
||||
name: "Personal chat identity", adapterType: "process",
|
||||
adapterConfig: { command: process.execPath, args: ["-e", "process.exit(0)"] },
|
||||
runtimeConfig: { heartbeat: { enabled: false } },
|
||||
} });
|
||||
expect(createdAgent.ok).toBe(true);
|
||||
const agentId = createdAgent.json!.id;
|
||||
const chatEndpoint = `${BASE}/api/companies/${company.id}/chats/${agentId}`;
|
||||
await page.goto(`${BASE}/${companyPrefix}/chats/${agentId}`);
|
||||
await invited.page.goto(`${BASE}/${companyPrefix}/chats/${agentId}`);
|
||||
expect((await sessionJsonRequest(page, chatEndpoint)).json).toBeNull();
|
||||
expect((await sessionJsonRequest(invited.page, chatEndpoint)).json).toBeNull();
|
||||
const ownerChat = await sessionJsonRequest<{ id: string; conversationUserId: string }>(page, chatEndpoint, { method: "POST", data: {} });
|
||||
const memberChat = await sessionJsonRequest<{ id: string; conversationUserId: string }>(invited.page, chatEndpoint, { method: "POST", data: {} });
|
||||
expect(ownerChat.ok).toBe(true); expect(memberChat.ok).toBe(true);
|
||||
expect(ownerChat.json!.id).not.toBe(memberChat.json!.id);
|
||||
expect(ownerChat.json!.conversationUserId).not.toBe(memberChat.json!.conversationUserId);
|
||||
expect((await sessionJsonRequest(invited.page, `${BASE}/api/issues/${ownerChat.json!.id}`)).ok).toBe(true);
|
||||
expect((await sessionJsonRequest(page, `${BASE}/api/issues/${memberChat.json!.id}`)).ok).toBe(true);
|
||||
await page.reload(); await invited.page.reload();
|
||||
await page.getByRole("button", { name: "Star Personal chat identity", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Unstar Personal chat identity", exact: true })).toBeAttached();
|
||||
await expect(invited.page.getByRole("button", { name: "Star Personal chat identity", exact: true })).toBeAttached();
|
||||
const ownerRecent = await page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip.recentAgentChats:")));
|
||||
const memberRecent = await invited.page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip.recentAgentChats:")));
|
||||
expect(ownerRecent.some(key => key.endsWith(ownerChat.json!.conversationUserId))).toBe(true);
|
||||
expect(memberRecent.some(key => key.endsWith(memberChat.json!.conversationUserId))).toBe(true);
|
||||
const another = await createCompanyForSession(page, `Private company ${runId}`);
|
||||
const forbidden = await sessionJsonRequest(invited.page, `${BASE}/api/companies/${another.id}/chats/${agentId}`);
|
||||
expect([403, 404]).toContain(forbidden.status);
|
||||
} finally {
|
||||
const restore = await sessionJsonRequest(page, `${BASE}/api/instance/settings/experimental`, { method: "PATCH", data: { enableAgentChat: originalFlags.json!.enableAgentChat } });
|
||||
expect(restore.ok).toBe(true);
|
||||
}
|
||||
} finally { await invited.context.close(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export default defineConfig({
|
|||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${path.resolve(import.meta.dirname, "fixtures/agent-chat-github.mjs")}`,
|
||||
PORT: String(PORT),
|
||||
PAPERCLIP_OPEN_ON_LISTEN: "false",
|
||||
PAPERCLIP_API_URL: BASE_URL,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { Workspaces } from "./pages/Workspaces";
|
|||
import { Issues } from "./pages/Issues";
|
||||
import { Search } from "./pages/Search";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { AgentChat } from "./pages/AgentChat";
|
||||
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
|
||||
import { Routines } from "./pages/Routines";
|
||||
import { Learnings, PipelineItemDetail, PipelineItemLegacyRedirect, Pipelines, ReviewQueue } from "./pages/Pipelines";
|
||||
|
|
@ -303,6 +304,7 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
|
|||
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="chats/:agentRef" element={<AgentChat />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
{import.meta.env.DEV ? (
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import type { Issue } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
export const agentChatsApi = {
|
||||
get: (companyId: string, agentRef: string) =>
|
||||
api.get<Issue | null>(
|
||||
`/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`,
|
||||
),
|
||||
ensure: (companyId: string, agentRef: string) =>
|
||||
api.post<Issue>(
|
||||
`/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
|
|
@ -501,10 +501,12 @@ export const issuesApi = {
|
|||
reopen?: boolean,
|
||||
interrupt?: boolean,
|
||||
attachmentIds?: string[],
|
||||
clientRequestId?: string,
|
||||
) =>
|
||||
confirmedCommentResponse(
|
||||
api.post<IssueComment>(`/issues/${id}/comments`, {
|
||||
body,
|
||||
...(clientRequestId ? { clientRequestId } : {}),
|
||||
...(reopen === undefined ? {} : { reopen }),
|
||||
...(interrupt === undefined ? {} : { interrupt }),
|
||||
...(attachmentIds?.length ? { attachmentIds } : {}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { useState } from "react";
|
||||
import { Star, Users } from "lucide-react";
|
||||
import { SidebarSection } from "@/components/SidebarSection";
|
||||
import { SidebarNavItem } from "@/components/SidebarNavItem";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useSidebar } from "@/context/SidebarContext";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { orderChatAgents } from "@/lib/recent-agent-chats";
|
||||
export function AgentChatSidebar({
|
||||
activeId,
|
||||
starredIds,
|
||||
recentIds,
|
||||
onToggleStar,
|
||||
agents,
|
||||
href = (id: string) =>
|
||||
`/chats/${encodeURIComponent(agentRouteRef(agents.find((agent) => agent.id === id)!))}`,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
href?: (id: string) => string;
|
||||
activeId: string;
|
||||
starredIds: string[];
|
||||
recentIds: string[];
|
||||
onToggleStar: (id: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
|
||||
const rail = collapsed && !peeking;
|
||||
const ordered = orderChatAgents(agents, starredIds, recentIds);
|
||||
const row = (agent: Agent) => {
|
||||
const pinned = starredIds.includes(agent.id);
|
||||
return (
|
||||
<div key={agent.id} className="group/agent-chat relative">
|
||||
<SidebarNavItem
|
||||
to={href(agent.id)}
|
||||
label={agent.name}
|
||||
active={activeId === agent.id}
|
||||
iconNode={
|
||||
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
|
||||
}
|
||||
className={rail ? undefined : "pr-9"}
|
||||
/>
|
||||
{!rail && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`${pinned ? "Unstar" : "Star"} ${agent.name}`}
|
||||
aria-pressed={pinned}
|
||||
title={pinned ? "Unstar agent" : "Star agent to pin"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleStar(agent.id);
|
||||
}}
|
||||
className="absolute right-2 top-(--pct-50) -translate-y-(--pct-50) text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/agent-chat:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Star
|
||||
aria-hidden="true"
|
||||
className={pinned ? "fill-current" : undefined}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SidebarSection
|
||||
label="Agents"
|
||||
collapsible={{ open, onOpenChange: setOpen }}
|
||||
>
|
||||
{ordered.map((agent) => row(agent))}
|
||||
<Link
|
||||
to="/agents/all"
|
||||
className="flex items-center gap-2.5 mx-2 rounded-lg px-2 py-1.5 pointer-coarse:py-1 text-(length:--text-compact) font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="See all agents"
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
>
|
||||
<Users className="h-4 w-4 shrink-0" />
|
||||
{!rail && <span>See all agents</span>}
|
||||
</Link>
|
||||
</SidebarSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -113,6 +113,33 @@ describe("BreadcrumbBar", () => {
|
|||
container.remove();
|
||||
});
|
||||
|
||||
it("keeps a single task breadcrumb compact with adjacent identity and settings action", async () => {
|
||||
const configure = vi.fn();
|
||||
function AgentBreadcrumb() {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{
|
||||
label: "CodexCoder",
|
||||
leading: <span aria-label="Agent avatar">CC</span>,
|
||||
leadingKey: "codex-avatar",
|
||||
trailing: <button aria-label="Configure CodexCoder" onClick={configure}>Settings</button>,
|
||||
trailingKey: "codex-settings",
|
||||
}]);
|
||||
}, [setBreadcrumbs]);
|
||||
return <BreadcrumbBar taskDetailLayout />;
|
||||
}
|
||||
await act(async () => root.render(<BreadcrumbProvider><AgentBreadcrumb /></BreadcrumbProvider>));
|
||||
const label = container.querySelector('[data-slot="breadcrumb-page"]');
|
||||
expect(label?.textContent).toBe("CCCodexCoder");
|
||||
expect(container.querySelector("h1")).toBeNull();
|
||||
const settings = container.querySelector<HTMLButtonElement>('button[aria-label="Configure CodexCoder"]');
|
||||
expect(settings?.closest('[data-slot="breadcrumb-item"]')).toBe(label?.closest('[data-slot="breadcrumb-item"]'));
|
||||
expect(settings?.closest('[aria-disabled="true"]')).toBeNull();
|
||||
act(() => settings?.click());
|
||||
expect(configure).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector('button[aria-label="Hide properties"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the title followed by its identifier for a company-scoped mobile task header", async () => {
|
||||
viewport.isMobile = true;
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
|
|||
)}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
{crumb.trailing && <span className="flex shrink-0 items-center">{crumb.trailing}</span>}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
);
|
||||
|
|
@ -187,8 +188,9 @@ export function BreadcrumbBar({ taskDetailLayout = false }: { taskDetailLayout?:
|
|||
</div>
|
||||
);
|
||||
|
||||
// Single breadcrumb = page title (uppercase)
|
||||
if (breadcrumbs.length === 1) {
|
||||
// Task details use the same breadcrumb typography even with one item.
|
||||
// Other single-crumb pages keep their existing page-title presentation.
|
||||
if (breadcrumbs.length === 1 && !taskDetailLayout) {
|
||||
return (
|
||||
<div className="h-(--sz-60px) shrink-0 flex items-center border-b border-border px-4 md:px-6">
|
||||
{menuButton}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
|
@ -75,7 +75,7 @@ const RESERVED_APP_SUBPATHS = new Set([
|
|||
"app",
|
||||
]);
|
||||
|
||||
export function Layout() {
|
||||
export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
const {
|
||||
sidebarOpen,
|
||||
setSidebarOpen,
|
||||
|
|
@ -654,7 +654,7 @@ export function Layout() {
|
|||
{hasSecondarySidebar ? (
|
||||
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
|
||||
) : (
|
||||
<Sidebar />
|
||||
<Sidebar>{sidebarSections}</Sidebar>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -678,7 +678,7 @@ export function Layout() {
|
|||
{replacesPrimarySidebar ? (
|
||||
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
|
||||
) : (
|
||||
<Sidebar />
|
||||
<Sidebar>{sidebarSections}</Sidebar>
|
||||
)}
|
||||
</div>
|
||||
<SidebarAccountMenu
|
||||
|
|
|
|||
|
|
@ -22,13 +22,15 @@ import {
|
|||
LayoutGrid,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { SidebarStarredProjects } from "./SidebarStarredProjects";
|
||||
import { SidebarAgentChats } from "./SidebarAgentChats";
|
||||
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
|
||||
import { SidebarRecentTasks } from "./SidebarRecentTasks";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
|
|
@ -48,8 +50,9 @@ import { PluginLauncherOutlet } from "@/plugins/launchers";
|
|||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
import { primarySidebarStyles } from "./primary-sidebar-styles";
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({ children }: { children?: ReactNode }) {
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { enabled: agentChatEnabled } = useAgentChatEnabled();
|
||||
// Every labeled section is collapsible (session-scoped, default open) —
|
||||
// one policy across static nav groups and the data-driven sections.
|
||||
const [workOpen, setWorkOpen] = useState(true);
|
||||
|
|
@ -244,6 +247,9 @@ export function Sidebar() {
|
|||
</SidebarSection>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
{agentChatEnabled && !children && <SidebarAgentChats />}
|
||||
|
||||
{streamlinedUiEnabled ? (
|
||||
<SidebarRecentTasks companyId={selectedCompanyId} liveIssueIds={liveIssueIds} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import {
|
||||
useResourceMemberships,
|
||||
useResourceMembershipMutation,
|
||||
} from "@/hooks/useResourceMemberships";
|
||||
import { useRecentAgentChats } from "@/lib/recent-agent-chats";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { AgentChatSidebar } from "./AgentChatSidebar";
|
||||
export function SidebarAgentChats() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { data: agents = [] } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const userId = session?.user?.id ?? session?.session?.userId;
|
||||
const recentIds = useRecentAgentChats(selectedCompanyId ?? "", userId);
|
||||
const memberships = useResourceMemberships(selectedCompanyId);
|
||||
const mutation = useResourceMembershipMutation(selectedCompanyId);
|
||||
const stars = memberships.data?.starredAgentIds ?? [];
|
||||
const location = useLocation();
|
||||
const activeRef = location.pathname.match(/\/chats\/([^/]+)/)?.[1];
|
||||
const active = agents.find(
|
||||
(agent) => agent.id === activeRef || agentRouteRef(agent) === activeRef,
|
||||
);
|
||||
return (
|
||||
<AgentChatSidebar
|
||||
agents={agents}
|
||||
activeId={active?.id ?? ""}
|
||||
starredIds={stars}
|
||||
recentIds={recentIds}
|
||||
onToggleStar={(id) => {
|
||||
mutation.mutate({
|
||||
resourceType: "agent",
|
||||
resourceId: id,
|
||||
resourceName:
|
||||
agents.find((agent) => agent.id === id)?.name ?? "Agent",
|
||||
starred: !stars.includes(id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1247,6 +1247,16 @@ describe("TaskChatThread runtime transcript selection", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("does not render an empty response notice for a conversation reset", () => {
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} linkedRuns={[{
|
||||
runId: "chat-reset", status: "succeeded", startedAt: null, resultJson: { conversationReset: true },
|
||||
agentId: "agent-1", agentName: "Claude", adapterType: "claude_local",
|
||||
createdAt: "2026-09-11T18:00:00.000Z", finishedAt: "2026-09-11T18:00:01.000Z",
|
||||
}]} />);
|
||||
expect(container.textContent).not.toContain("The runner returned no user-facing response.");
|
||||
expect(container.textContent).not.toContain("Run completed");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["legacy", "issue_not_in_progress"],
|
||||
["native", "issue_not_in_progress"],
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { useProjectCreatedItems } from "@/hooks/useProjectCreatedItems";
|
||||
import { requiresExecutionReconciliation } from "@paperclipai/shared";
|
||||
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
|
||||
import { TaskChatScrollReady } from "@/components/task-chat/scroll-navigation";
|
||||
|
|
@ -394,6 +396,8 @@ function resolvedWithoutUserFacingResponse(value: unknown): boolean {
|
|||
}
|
||||
|
||||
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread> & {
|
||||
conversationMode?: boolean;
|
||||
creationActivity?: ActivityEvent[];
|
||||
initialHistoryPending?: boolean;
|
||||
initialHistoryError?: boolean;
|
||||
onRetryInitialHistory?: () => void;
|
||||
|
|
@ -499,6 +503,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
imageUploadHandler,
|
||||
mentions,
|
||||
enableReassign,
|
||||
conversationMode,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
issueStatus,
|
||||
|
|
@ -536,6 +541,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
resumeAssigneePending = false,
|
||||
} = props;
|
||||
const queryClient = useQueryClient();
|
||||
const createdProjectItems = useProjectCreatedItems(props.creationActivity ?? [], companyId);
|
||||
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
|
@ -1266,10 +1272,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
},
|
||||
});
|
||||
}
|
||||
for (const item of createdProjectItems) {
|
||||
entries.push({ id: item.id, item, ms: toMs(item.timestamp), order: 2 });
|
||||
}
|
||||
return entries.sort(
|
||||
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
|
||||
);
|
||||
}, [
|
||||
createdProjectItems,
|
||||
comments,
|
||||
projectedComments,
|
||||
commentItems,
|
||||
|
|
@ -1386,6 +1396,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
if (liveRun && source.id === liveRun.id) continue;
|
||||
const entries = transcriptByRun.get(source.id) ?? [];
|
||||
const meta = linkedRunMetaById.get(source.id);
|
||||
// /new is represented by its durable comment boundary, not an empty
|
||||
// model response or a completed-run notice.
|
||||
if (meta?.resultJson?.conversationReset === true) { settledRunIds.add(source.id); continue; }
|
||||
// A queued continuation can become unnecessary while another turn finishes
|
||||
// the task. Keep that cancellation in the run log, not the conversation.
|
||||
// Apply this before native stop markers are assembled as well.
|
||||
|
|
@ -1652,8 +1665,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
id,
|
||||
kind: "marker",
|
||||
variant: "turn_boundary",
|
||||
label: "Run completed",
|
||||
detail: "The runner returned no user-facing response.",
|
||||
label: source.status === "cancelled" ? "Stopped" : "Run completed",
|
||||
detail: source.status === "cancelled" ? "This turn was cancelled before it returned a response." : "The runner returned no user-facing response.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -2960,6 +2973,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
onImageUpload={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
enableReassign={enableReassign}
|
||||
conversationMode={conversationMode}
|
||||
reassignOptions={reassignOptions}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
|
|
|
|||
|
|
@ -1714,6 +1714,19 @@ describe("TaskChatComposer", () => {
|
|||
});
|
||||
|
||||
describe("paused task takeover", () => {
|
||||
it("allows only standalone /new to resume a paused conversation through the normal composer", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} conversationMode workMode="standard" pause={{ scope: "leaf" }} />);
|
||||
typeText("Keep working");
|
||||
expect(sendButton().disabled).toBe(true);
|
||||
await act(async () => sendButton().click());
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
typeText("/new");
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
await act(async () => sendButton().click());
|
||||
expect(onAdd).toHaveBeenCalledWith("/new", undefined, undefined);
|
||||
});
|
||||
|
||||
it("preserves a typed draft and blocks sending until resume completes", async () => {
|
||||
const onAdd = vi.fn();
|
||||
const onResume = vi.fn();
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ interface TaskChatComposerProps {
|
|||
/** Mentionable entities for the editor's @-autocomplete. */
|
||||
mentions?: MentionOption[];
|
||||
enableReassign?: boolean;
|
||||
conversationMode?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
agentMap?: ReadonlyMap<string, { icon?: string | null }>;
|
||||
userProfileMap?: ReadonlyMap<
|
||||
|
|
@ -391,6 +392,7 @@ export function TaskChatComposer({
|
|||
onImageUpload,
|
||||
mentions,
|
||||
enableReassign = false,
|
||||
conversationMode = false,
|
||||
reassignOptions,
|
||||
agentMap,
|
||||
userProfileMap,
|
||||
|
|
@ -591,6 +593,7 @@ export function TaskChatComposer({
|
|||
|
||||
const modeMeta = workModeMetaFor(pendingMode);
|
||||
const canAcceptFiles =
|
||||
!pause &&
|
||||
!queuedEdit &&
|
||||
!uncertainSubmission &&
|
||||
Boolean(onAttachImage || onImageUpload);
|
||||
|
|
@ -797,7 +800,7 @@ export function TaskChatComposer({
|
|||
const uploadPending = attachments.some((item) => item.status === "uploading");
|
||||
const uploadFailed = attachments.some((item) => item.status === "error");
|
||||
const takeoverVisible = Boolean(
|
||||
takeover && !queuedEdit && !submitting && !uploadPending,
|
||||
takeover && !pause && !queuedEdit && !submitting && !uploadPending,
|
||||
);
|
||||
const previousTakeoverVisibleRef = useRef(takeoverVisible);
|
||||
useEffect(() => {
|
||||
|
|
@ -807,8 +810,10 @@ export function TaskChatComposer({
|
|||
previousTakeoverVisibleRef.current = takeoverVisible;
|
||||
}, [queuedEdit, takeoverVisible]);
|
||||
|
||||
const canResetPausedConversation = conversationMode && !queuedEdit && body.trim() === "/new" && attachments.length === 0;
|
||||
|
||||
async function submit() {
|
||||
if (pause || disabled) return;
|
||||
if (disabled || (pause && !canResetPausedConversation)) return;
|
||||
const retained =
|
||||
draftKey && !queuedEdit ? loadDraftSubmission(draftKey) : null;
|
||||
if (retained && !submitting) {
|
||||
|
|
@ -822,6 +827,10 @@ export function TaskChatComposer({
|
|||
const goalCommand = queuedEdit
|
||||
? ({ matched: false } as const)
|
||||
: parseRunnerGoalCommand(submittedBody);
|
||||
if (goalCommand.matched && conversationMode) {
|
||||
setActionError("Create a separate task for work that needs an ongoing execution goal.");
|
||||
return;
|
||||
}
|
||||
if (goalCommand.matched) {
|
||||
if ("error" in goalCommand) {
|
||||
setActionError(goalCommand.error);
|
||||
|
|
@ -1064,7 +1073,7 @@ export function TaskChatComposer({
|
|||
</Button>
|
||||
) : null;
|
||||
|
||||
if (pause) {
|
||||
if (pause && (!conversationMode || queuedEdit)) {
|
||||
return <TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />;
|
||||
}
|
||||
|
||||
|
|
@ -1243,6 +1252,12 @@ export function TaskChatComposer({
|
|||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{pause && conversationMode ? (
|
||||
<div className="space-y-2">
|
||||
<TaskChatPausedTakeover {...pause} hasDraft={Boolean(body.trim() || attachments.length)} />
|
||||
<p className="text-xs text-muted-foreground">Send /new to start a fresh session and resume this conversation.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div data-testid="task-chat-composer-input">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
|
|
@ -1255,7 +1270,11 @@ export function TaskChatComposer({
|
|||
}
|
||||
readOnly={disabled || !!uncertainSubmission}
|
||||
mentions={mentions}
|
||||
actionCommands={[goalCommandOption]}
|
||||
actionCommands={conversationMode ? [{
|
||||
id: "action:new", kind: "action", command: "new", name: "New session",
|
||||
description: "Start fresh context here, preserving conversation history.", aliases: ["new"],
|
||||
disabled,
|
||||
}] : [goalCommandOption]}
|
||||
onSubmit={() => void submit()}
|
||||
imageUploadHandler={
|
||||
canAcceptFiles ? uploadInlineImage : undefined
|
||||
|
|
@ -1504,6 +1523,7 @@ export function TaskChatComposer({
|
|||
showStop
|
||||
? disabled || stopControl.stopping
|
||||
: disabled ||
|
||||
(Boolean(pause) && !canResetPausedConversation) ||
|
||||
submitting ||
|
||||
!!uncertainSubmission ||
|
||||
uploadPending ||
|
||||
|
|
|
|||
|
|
@ -72,4 +72,3 @@ export function TaskChatPausedTakeover({
|
|||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { FolderKanban, GitBranch } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import type { TaskChatProjectCreatedItem } from "./task-chat-model";
|
||||
|
||||
export function TaskChatProjectCreatedCard({ item }: { item: TaskChatProjectCreatedItem }) {
|
||||
return (
|
||||
<article className="rounded-lg border border-border bg-card p-3" aria-label={`Project created: ${item.name}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<FolderKanban className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Project created</p>
|
||||
<Link to={`/projects/${item.projectId}`} className="break-words text-sm font-medium hover:underline focus-visible:underline">{item.name}</Link>
|
||||
{item.description && <p className="line-clamp-3 text-sm text-muted-foreground">{item.description}</p>}
|
||||
{item.repositories.length > 0 && <ul className="space-y-1 pt-1">
|
||||
{item.repositories.map(repo => <li key={repo.id} className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
<GitBranch className="size-3 shrink-0" aria-hidden />
|
||||
<a className="truncate hover:underline focus-visible:underline" href={repo.url} target="_blank" rel="noreferrer">{repo.name}</a>
|
||||
</li>)}
|
||||
</ul>}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { IssueAttachment } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -93,6 +94,7 @@ function renderItem(
|
|||
attachments: IssueAttachment[] = [],
|
||||
) {
|
||||
switch (item.kind) {
|
||||
case "project_created": return <TaskChatProjectCreatedCard item={item} />;
|
||||
case "message": {
|
||||
// Compute the actions once: the bubble renders them for a runless reply
|
||||
// (footer = actions + timestamp), while an attached turn hands them to
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { projectCreatedItems } from "./project-created-items";
|
||||
const event = (overrides: Partial<ActivityEvent> = {}): ActivityEvent =>
|
||||
({
|
||||
id: "activity",
|
||||
companyId: "company",
|
||||
actorType: "agent",
|
||||
actorId: "agent",
|
||||
agentId: "agent",
|
||||
runId: "run",
|
||||
action: "project.created",
|
||||
entityType: "project",
|
||||
entityId: "project",
|
||||
createdAt: new Date("2026-09-11T12:00:00Z"),
|
||||
details: {
|
||||
name: "Launch",
|
||||
repositories: [
|
||||
{ id: "1", name: "org/app", url: "https://github.com/org/app" },
|
||||
{ id: "2", name: "org/docs", url: "https://github.com/org/docs" },
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
}) as ActivityEvent;
|
||||
describe("durable project creation feed", () => {
|
||||
it("deduplicates repeated receipts while retaining all repositories", () => {
|
||||
const items = projectCreatedItems([event(), event({ id: "replay" })]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({
|
||||
kind: "project_created",
|
||||
projectId: "project",
|
||||
name: "Launch",
|
||||
});
|
||||
expect(items[0].repositories).toHaveLength(2);
|
||||
expect(projectCreatedItems(JSON.parse(JSON.stringify([event()])))).toEqual(
|
||||
items,
|
||||
);
|
||||
});
|
||||
it("does not turn a failed tool call or agent claim into a success card", () => {
|
||||
expect(
|
||||
projectCreatedItems([
|
||||
event({ action: "runner.api_called" }),
|
||||
event({ action: "issue.comment_added" }),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
it("omits unsafe repository links and accepts projects without repositories", () => {
|
||||
expect(
|
||||
projectCreatedItems([
|
||||
event({
|
||||
details: {
|
||||
name: "Research",
|
||||
repositories: [
|
||||
{ id: "unsafe", name: "unsafe", url: "javascript:alert(1)" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
])[0].repositories,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
projectCreatedItems([event({ details: { name: "Research" } })])[0].name,
|
||||
).toBe("Research");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project creation repository hydration", () => {
|
||||
const project = {
|
||||
id: "project",
|
||||
companyId: "company",
|
||||
workspaces: [
|
||||
{ id: "workspace-1", name: "App", repoUrl: "https://github.com/org/app" },
|
||||
{
|
||||
id: "workspace-2",
|
||||
name: "Docs",
|
||||
repoUrl: "https://github.com/org/docs",
|
||||
},
|
||||
],
|
||||
} as import("@paperclipai/shared").Project;
|
||||
|
||||
it("includes repositories added after creation without adding another card", () => {
|
||||
const receipt = event({
|
||||
details: {
|
||||
name: "Launch",
|
||||
repositories: [
|
||||
{ id: "1", name: "App", url: "https://github.com/org/app" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const items = projectCreatedItems([receipt, receipt], [project]);
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].repositories.map((repo) => repo.url)).toEqual([
|
||||
"https://github.com/org/app",
|
||||
"https://github.com/org/docs",
|
||||
]);
|
||||
expect(projectCreatedItems([], [project])).toEqual([]);
|
||||
expect(items[0].timestamp).toBe("2026-09-11T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("reflects repository removal but falls back when current project data is unavailable", () => {
|
||||
expect(
|
||||
projectCreatedItems([event()], [{ ...project, workspaces: [] }])[0]
|
||||
.repositories,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
projectCreatedItems(
|
||||
[event()],
|
||||
[{ ...project, companyId: "other-company" }],
|
||||
),
|
||||
).toEqual(projectCreatedItems([event()]));
|
||||
});
|
||||
|
||||
it("deduplicates workspaces and rejects unsafe current repository links", () => {
|
||||
const workspaces = [
|
||||
project.workspaces[0],
|
||||
project.workspaces[0],
|
||||
{ ...project.workspaces[1], repoUrl: "javascript:alert(1)" },
|
||||
];
|
||||
expect(
|
||||
projectCreatedItems([event()], [{ ...project, workspaces }])[0]
|
||||
.repositories,
|
||||
).toEqual([
|
||||
{ id: "workspace-1", name: "App", url: "https://github.com/org/app" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import type { ActivityEvent, Project } from "@paperclipai/shared";
|
||||
import type { TaskChatProjectCreatedItem } from "./task-chat-model";
|
||||
|
||||
export function projectCreatedItems(
|
||||
events: readonly ActivityEvent[],
|
||||
projects: readonly Pick<Project, "id" | "companyId" | "workspaces">[] = [],
|
||||
): TaskChatProjectCreatedItem[] {
|
||||
const seen = new Set<string>();
|
||||
return events.flatMap((event) => {
|
||||
if (
|
||||
event.action !== "project.created" ||
|
||||
event.entityType !== "project" ||
|
||||
seen.has(event.entityId)
|
||||
)
|
||||
return [];
|
||||
const details = event.details ?? {};
|
||||
if (typeof details.name !== "string") return [];
|
||||
seen.add(event.entityId);
|
||||
const project = projects.find(
|
||||
(candidate) =>
|
||||
candidate.id === event.entityId &&
|
||||
candidate.companyId === event.companyId,
|
||||
);
|
||||
const repositoryDetails = project
|
||||
? project.workspaces.map((workspace) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
url: workspace.repoUrl,
|
||||
}))
|
||||
: details.repositories;
|
||||
const seenUrls = new Set<string>();
|
||||
const repositories = Array.isArray(repositoryDetails)
|
||||
? repositoryDetails.flatMap((value) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const repo = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof repo.id !== "string" ||
|
||||
typeof repo.name !== "string" ||
|
||||
typeof repo.url !== "string" ||
|
||||
!/^https:\/\//.test(repo.url)
|
||||
)
|
||||
return [];
|
||||
if (seenUrls.has(repo.url)) return [];
|
||||
seenUrls.add(repo.url);
|
||||
return [{ id: repo.id, name: repo.name, url: repo.url }];
|
||||
})
|
||||
: [];
|
||||
return [
|
||||
{
|
||||
id: `project-created:${event.entityId}`,
|
||||
kind: "project_created" as const,
|
||||
projectId: event.entityId,
|
||||
name: details.name,
|
||||
description:
|
||||
typeof details.description === "string" ? details.description : null,
|
||||
repositories,
|
||||
timestamp: new Date(event.createdAt).toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
@ -85,6 +85,11 @@ export function commentsToTaskChatItems(
|
|||
const items: TaskChatItem[] = [];
|
||||
for (const comment of comments) {
|
||||
if (comment.deletedAt) continue;
|
||||
if (comment.conversationSessionGeneration != null) {
|
||||
items.push({ id: comment.id, kind: "marker", variant: "session_start", label: "New session",
|
||||
detail: "Earlier messages and files are still available.", createdAtIso: new Date(comment.createdAt).toISOString() });
|
||||
continue;
|
||||
}
|
||||
const kind = authorKind(comment);
|
||||
let authorName: string | undefined;
|
||||
let agentIcon: string | null | undefined;
|
||||
|
|
|
|||
|
|
@ -530,7 +530,18 @@ export interface TaskChatTurnItem {
|
|||
};
|
||||
}
|
||||
|
||||
export interface TaskChatProjectCreatedItem {
|
||||
id: string;
|
||||
kind: "project_created";
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
repositories: { id: string; name: string; url: string }[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export type TaskChatItem =
|
||||
| TaskChatProjectCreatedItem
|
||||
| TaskChatMessageItem
|
||||
| TaskChatThinkingItem
|
||||
| TaskChatToolItem
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ export function TaskSidePanel({
|
|||
const autoPlanHandledRef = useRef(restoredRef.current?.autoPlanHandled ?? false);
|
||||
const initialState = useMemo(() => {
|
||||
const restored = restoredRef.current?.state;
|
||||
let tabs = restored?.tabs ?? [taskPanelPropertiesTab()];
|
||||
let tabs = restored?.tabs ?? (issue.conversationAgentId ? [taskPanelArtifactsTab()] : [taskPanelPropertiesTab()]);
|
||||
if (!initialSubtasksAvailableRef.current) {
|
||||
tabs = tabs.filter((tab) => tab.payload.kind !== "subtasks");
|
||||
} else if (!subtasksDismissedRef.current) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ export interface Breadcrumb {
|
|||
* a primitive that changes only when the rendered `leading` should change.
|
||||
*/
|
||||
leadingKey?: string;
|
||||
/** Optional action beside the label, outside the breadcrumb link. */
|
||||
trailing?: ReactNode;
|
||||
/** Stable identity for the action, following leadingKey semantics. */
|
||||
trailingKey?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbContextValue {
|
||||
|
|
@ -50,6 +54,7 @@ function breadcrumbsEqual(left: Breadcrumb[], right: Breadcrumb[]) {
|
|||
|| left[index]?.href !== right[index]?.href
|
||||
|| left[index]?.identifier !== right[index]?.identifier
|
||||
|| left[index]?.leadingKey !== right[index]?.leadingKey
|
||||
|| left[index]?.trailingKey !== right[index]?.trailingKey
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,40 @@ vi.mock("../api/issues", () => ({
|
|||
}));
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
it("connects trusted local boards without admitting signed-out authenticated users", () => {
|
||||
const canConnect = __liveUpdatesTestUtils.canUseLiveSession;
|
||||
expect(canConnect("success", false, "local_trusted")).toBe(true);
|
||||
expect(canConnect("success", false, "authenticated")).toBe(false);
|
||||
expect(canConnect("success", false, undefined)).toBe(false);
|
||||
expect(canConnect("pending", false, "local_trusted")).toBe(false);
|
||||
expect(canConnect("success", true, "authenticated")).toBe(true);
|
||||
});
|
||||
it("uses the current person's canonical chat for live updates and refreshes reset boundaries", () => {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||
client.setQueryData(queryKeys.companies.list("user-1"), { companies: [{ id: "company-1", issuePrefix: "PAP" }], unauthorized: false });
|
||||
client.setQueryData(queryKeys.agents.list("company-1"), [{ id: "agent-1", name: "Coder", urlKey: "coder" }]);
|
||||
const chat = { id: "chat-1", companyId: "company-1", identifier: "PAP-1", assigneeAgentId: "agent-1" };
|
||||
client.setQueryData(queryKeys.agentChats.detail("company-1", "user-1", "agent-1"), chat);
|
||||
client.setQueryData(queryKeys.agentChats.detail("company-1", "user-2", "agent-1"), { ...chat, id: "other-chat" });
|
||||
client.setQueryData(queryKeys.issues.detail("chat-1"), chat);
|
||||
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(client, "/PAP/chats/agent-1", { issueId: "chat-1", runId: "run-1" }, { isForegrounded: true })).toBe(true);
|
||||
expect(__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(client, "/PAP/chats/agent-1", { issueId: "other-chat", runId: "run-2" }, { isForegrounded: true })).toBe(false);
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", { entityType: "issue", entityId: "chat-1", action: "issue.conversation_session_started", actorType: "system" }, { userId: "user-1", agentId: null }, { pathname: "/PAP/chats/agent-1", isForegrounded: true });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("chat-1") });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["issues", "tree-control-state", "chat-1"] });
|
||||
invalidate.mockClear();
|
||||
__liveUpdatesTestUtils.invalidateVisibleIssueRunQueries(client, "/PAP/chats/agent-1", { agentId: "agent-1", runId: "run-1", status: "succeeded" }, { isForegrounded: true });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments("chat-1") });
|
||||
client.clear();
|
||||
});
|
||||
|
||||
it("refreshes touched inbox queries and only the changed issue data for issue updates", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
|||
import type { CompanyUserDirectoryResponse } from "../api/access";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { authApi } from "../api/auth";
|
||||
import type { CompanyListResult } from "../api/companies-query";
|
||||
import { healthApi } from "../api/health";
|
||||
import { useCompany } from "./CompanyContext";
|
||||
import type { ToastInput } from "./ToastContext";
|
||||
import { useToastActions } from "./ToastContext";
|
||||
|
|
@ -43,8 +45,9 @@ import {
|
|||
removeLiveRunById,
|
||||
} from "../lib/optimistic-issue-runs";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import { extractCompanyPrefixFromPath, toCompanyRelativePath } from "../lib/company-routes";
|
||||
import { useLocation } from "../lib/router";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
|
||||
|
||||
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||
|
|
@ -296,11 +299,24 @@ function resolveVisibleIssueRouteContext(
|
|||
|
||||
const relativePath = toCompanyRelativePath(pathname);
|
||||
const segments = relativePath.split("/").filter(Boolean);
|
||||
if (segments[0] !== "issues" || !segments[1]) return null;
|
||||
if (!["issues", "chats"].includes(segments[0]) || !segments[1]) return null;
|
||||
|
||||
const issueRef = decodeURIComponent(segments[1]);
|
||||
const issue =
|
||||
queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
|
||||
let issueRef = decodeURIComponent(segments[1]);
|
||||
if (segments[0] === "chats") {
|
||||
const session = queryClient.getQueryData<Awaited<ReturnType<typeof authApi.getSession>>>(queryKeys.auth.session);
|
||||
const userId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const companyPrefix = extractCompanyPrefixFromPath(pathname);
|
||||
const company = queryClient.getQueryData<CompanyListResult>(queryKeys.companies.list(userId))
|
||||
?.companies.find(item => item.issuePrefix.toUpperCase() === companyPrefix?.toUpperCase());
|
||||
if (!company) return null;
|
||||
const agent = queryClient.getQueryData<Agent[]>(queryKeys.agents.list(company.id))
|
||||
?.find(item => item.id === issueRef || agentRouteRef(item) === issueRef);
|
||||
if (!agent) return null;
|
||||
const conversation = queryClient.getQueryData<Issue | null>(queryKeys.agentChats.detail(company.id, userId, agent.id));
|
||||
if (!conversation) return null;
|
||||
issueRef = conversation.id;
|
||||
}
|
||||
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
|
||||
const issueRefs = new Set<string>([issueRef]);
|
||||
if (issue?.id) issueRefs.add(issue.id);
|
||||
if (issue?.identifier) issueRefs.add(issue.identifier);
|
||||
|
|
@ -502,21 +518,17 @@ function invalidateVisibleIssueRunQueries(
|
|||
}
|
||||
|
||||
for (const issueRef of context.issueRefs) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.detail(issueRef),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.activity(issueRef),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.runs(issueRef),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.liveRuns(issueRef),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.activeRun(issueRef),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueRef) });
|
||||
if (status && TERMINAL_RUN_STATUSES.has(status)) {
|
||||
// A final comment can race the last in-flight history fetch. Reconcile
|
||||
// persisted messages after the turn settles.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueRef) });
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueRef] });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1318,19 +1330,14 @@ function invalidateActivityQueries(
|
|||
visibleIssueCommentActivity
|
||||
? { refetchType: "inactive" as const }
|
||||
: undefined;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.detail(ref),
|
||||
...invalidationOptions,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.activity(ref),
|
||||
...invalidationOptions,
|
||||
});
|
||||
if (action === "issue.comment_added") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.comments(ref),
|
||||
...invalidationOptions,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), ...invalidationOptions });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), ...invalidationOptions });
|
||||
if (action === "issue.comment_added" || action === "issue.conversation_session_started") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref), ...invalidationOptions });
|
||||
}
|
||||
if (action === "issue.conversation_session_started") {
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", ref] });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
|
||||
}
|
||||
if (action && ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action)) {
|
||||
const documentKey = readString(details?.key);
|
||||
|
|
@ -1417,13 +1424,10 @@ function invalidateActivityQueries(
|
|||
}
|
||||
|
||||
if (entityType === "project") {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.all(companyId),
|
||||
});
|
||||
if (entityId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.detail(entityId),
|
||||
});
|
||||
const sourceIssueId = readString((payload.details as Record<string, unknown> | undefined)?.sourceIssueId);
|
||||
if (sourceIssueId) queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(sourceIssueId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
|
||||
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(entityId) });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1784,6 +1788,7 @@ export const __liveUpdatesTestUtils = {
|
|||
invalidateVisibleIssueRunQueries,
|
||||
readRunLiveStatusPatchFromPayload,
|
||||
resolveLiveCompanyId,
|
||||
canUseLiveSession,
|
||||
shouldDeferIssueRefetchForVisibleAgentActivity,
|
||||
shouldDeferVisibleIssueCommentActivity,
|
||||
shouldSuppressActivityToastForVisibleIssue,
|
||||
|
|
@ -1791,6 +1796,10 @@ export const __liveUpdatesTestUtils = {
|
|||
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||
};
|
||||
|
||||
function canUseLiveSession(sessionStatus: string, hasSession: boolean, deploymentMode?: string) {
|
||||
return sessionStatus === "success" && (hasSession || deploymentMode === "local_trusted");
|
||||
}
|
||||
|
||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const { visible } = usePageVisibility();
|
||||
const wasHidden = useRef(!visible);
|
||||
|
|
@ -1808,18 +1817,12 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: healthApi.get });
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out";
|
||||
const liveCompanyId = resolveLiveCompanyId(
|
||||
selectedCompanyId,
|
||||
selectedCompany?.id ?? null,
|
||||
);
|
||||
const canConnectSocket =
|
||||
sessionStatus === "success" && session !== null && liveCompanyId !== null;
|
||||
const currentActorRef = useRef<{
|
||||
userId: string | null;
|
||||
agentId: string | null;
|
||||
}>({
|
||||
const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null);
|
||||
const canConnectSocket = canUseLiveSession(sessionStatus, session != null, health?.deploymentMode) && liveCompanyId !== null;
|
||||
const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({
|
||||
userId: currentUserId,
|
||||
agentId: null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
export function useAgentChatEnabled() {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
return {
|
||||
enabled: query.data?.enableAgentChat === true,
|
||||
loaded: query.isFetched,
|
||||
};
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import { queryKeys } from "@/lib/queryKeys";
|
|||
export function useIssueDocuments(issueId: string | null | undefined) {
|
||||
return useQuery<IssueDocument[]>({
|
||||
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "list"],
|
||||
enabled: Boolean(issueId),
|
||||
enabled: Boolean(issueId) && !issueId?.startsWith("chat:"),
|
||||
queryFn: () => issuesApi.listDocuments(issueId!),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { queryKeys } from "@/lib/queryKeys";
|
|||
export function useIssuePlanDocument(issueId: string | null | undefined) {
|
||||
return useQuery<IssueDocument | null>({
|
||||
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "plan"],
|
||||
enabled: Boolean(issueId),
|
||||
enabled: Boolean(issueId) && !issueId?.startsWith("chat:"),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await issuesApi.getDocument(issueId!, "plan");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ActivityEvent, Project } from "@paperclipai/shared";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useProjectCreatedItems } from "./useProjectCreatedItems";
|
||||
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: { get: vi.fn() } }));
|
||||
const receipt = {
|
||||
id: "receipt",
|
||||
companyId: "company",
|
||||
action: "project.created",
|
||||
entityType: "project",
|
||||
entityId: "project",
|
||||
createdAt: "2026-09-11T12:00:00Z",
|
||||
details: {
|
||||
name: "Launch",
|
||||
repositories: [
|
||||
{ id: "repo", name: "Original", url: "https://github.com/org/app" },
|
||||
],
|
||||
},
|
||||
} as unknown as ActivityEvent;
|
||||
const events = [receipt];
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let client: QueryClient;
|
||||
function Fixture({ activity = events }: { activity?: ActivityEvent[] }) {
|
||||
const items = useProjectCreatedItems(activity, "company");
|
||||
return <pre>{JSON.stringify(items)}</pre>;
|
||||
}
|
||||
function render(activity = events) {
|
||||
act(() =>
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Fixture activity={activity} />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
client.clear();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
describe("shared project creation card queries", () => {
|
||||
it("refreshes repositories through existing project invalidation", async () => {
|
||||
const project = {
|
||||
id: "project",
|
||||
companyId: "company",
|
||||
workspaces: [
|
||||
{ id: "first", name: "App", repoUrl: "https://github.com/org/app" },
|
||||
],
|
||||
} as Project;
|
||||
vi.mocked(projectsApi.get).mockResolvedValue(project);
|
||||
render();
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain('"name":"App"'),
|
||||
);
|
||||
expect(projectsApi.get).toHaveBeenCalledWith("project", "company");
|
||||
vi.mocked(projectsApi.get).mockResolvedValue({
|
||||
...project,
|
||||
workspaces: [
|
||||
...project.workspaces,
|
||||
{
|
||||
...project.workspaces[0],
|
||||
id: "second",
|
||||
name: "Docs",
|
||||
repoUrl: "https://github.com/org/docs",
|
||||
},
|
||||
],
|
||||
});
|
||||
await act(async () => {
|
||||
await client.invalidateQueries({
|
||||
queryKey: queryKeys.projects.detail("project"),
|
||||
});
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(container.textContent).toContain("https://github.com/org/docs"),
|
||||
);
|
||||
expect(JSON.parse(container.textContent!)).toHaveLength(1);
|
||||
});
|
||||
it("retains the creation receipt if the project is inaccessible", async () => {
|
||||
vi.mocked(projectsApi.get).mockRejectedValue(new Error("Not found"));
|
||||
render();
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
client.getQueryState(queryKeys.projects.detail("project"))?.status,
|
||||
).toBe("error"),
|
||||
);
|
||||
expect(container.textContent).toContain('"name":"Original"');
|
||||
expect(projectsApi.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("does not query projects or invent creation cards without receipts", () => {
|
||||
render([]);
|
||||
expect(projectsApi.get).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toBe("[]");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { useMemo } from "react";
|
||||
import { useQueries, type UseQueryResult } from "@tanstack/react-query";
|
||||
import type { ActivityEvent, Project } from "@paperclipai/shared";
|
||||
import { projectsApi } from "@/api/projects";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { projectCreatedItems } from "@/components/task-chat/project-created-items";
|
||||
|
||||
function availableProjects(results: UseQueryResult<Project>[]) {
|
||||
return results.flatMap((result) => (result.isSuccess ? [result.data] : []));
|
||||
}
|
||||
|
||||
/** Creation receipts establish the cards; authorized project reads keep their
|
||||
* repository lists current as the same run adds or edits workspaces.
|
||||
*/
|
||||
export function useProjectCreatedItems(
|
||||
events: readonly ActivityEvent[],
|
||||
companyId?: string | null,
|
||||
) {
|
||||
const receipts = useMemo(() => projectCreatedItems(events), [events]);
|
||||
const projects = useQueries({
|
||||
queries: receipts.map((receipt) => ({
|
||||
queryKey: queryKeys.projects.detail(receipt.projectId),
|
||||
queryFn: () => projectsApi.get(receipt.projectId, companyId!),
|
||||
enabled: Boolean(companyId),
|
||||
retry: false,
|
||||
})),
|
||||
combine: availableProjects,
|
||||
});
|
||||
return useMemo(
|
||||
() => projectCreatedItems(events, projects),
|
||||
[events, projects],
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { Agent, Issue, IssueWorkMode } from "@paperclipai/shared";
|
||||
/** Ephemeral view model; never persisted until first send or upload. */
|
||||
export function agentChatDraft(
|
||||
agent: Agent,
|
||||
workMode: IssueWorkMode = "standard",
|
||||
): Issue {
|
||||
return {
|
||||
id: `chat:${agent.id}`,
|
||||
companyId: agent.companyId,
|
||||
title: `Chat with ${agent.name}`,
|
||||
conversationAgentId: agent.id,
|
||||
conversationUserId: "draft",
|
||||
conversationState: "waiting",
|
||||
status: "in_review",
|
||||
workMode,
|
||||
priority: "medium",
|
||||
reviewPolicy: null,
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
description: null,
|
||||
assigneeAgentId: agent.id,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
responsibleUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
documentSummaries: [],
|
||||
labels: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { loadStructuredDraft, saveStructuredDraft } from "./composer-draft";
|
||||
|
||||
type PendingMessage = { body: string; id: string };
|
||||
const pending = new Map<string, PendingMessage[]>();
|
||||
const storageKey = (scope: string) => `paperclip:agent-chat-pending:${scope}`;
|
||||
function read(scope: string): PendingMessage[] {
|
||||
const stored = loadStructuredDraft<unknown>(
|
||||
storageKey(scope),
|
||||
pending.get(scope) ?? [],
|
||||
);
|
||||
return Array.isArray(stored)
|
||||
? stored.filter(
|
||||
(item): item is PendingMessage =>
|
||||
typeof item?.body === "string" && typeof item?.id === "string",
|
||||
)
|
||||
: [];
|
||||
}
|
||||
function write(scope: string, messages: PendingMessage[]) {
|
||||
pending.set(scope, messages);
|
||||
saveStructuredDraft(storageKey(scope), messages);
|
||||
}
|
||||
/** Preserve retry identity alongside the draft across agent switches and reloads. */
|
||||
export function chatMessageRequestId(scope: string, body: string): string {
|
||||
const messages = read(scope);
|
||||
const existing = messages.find((message) => message.body === body);
|
||||
if (existing) return existing.id;
|
||||
const id = crypto.randomUUID();
|
||||
write(scope, [...messages.slice(-9), { body, id }]);
|
||||
return id;
|
||||
}
|
||||
export function acknowledgeChatMessage(scope: string, id: string) {
|
||||
write(
|
||||
scope,
|
||||
read(scope).filter((message) => message.id !== id),
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"teams-catalog",
|
||||
"org",
|
||||
"agents",
|
||||
"chats",
|
||||
"apps",
|
||||
"projects",
|
||||
"workspaces",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
export const queryKeys = {
|
||||
agentChats: {
|
||||
detail: (companyId: string | null, userId: string | null, agentId: string | undefined) =>
|
||||
["agent-chat", companyId, userId, agentId] as const,
|
||||
},
|
||||
companies: {
|
||||
/**
|
||||
* Prefix for everything company-shaped. Matches the list, details and stats
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
import {
|
||||
acknowledgeChatMessage,
|
||||
chatMessageRequestId,
|
||||
} from "./chat-message-request";
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
orderChatAgents,
|
||||
parseRecentAgentChats,
|
||||
recordAgentChatVisit,
|
||||
} from "./recent-agent-chats";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import type { IssueChatComment } from "./issue-chat-messages";
|
||||
|
||||
describe("agent chat navigation and session markers", () => {
|
||||
it("sorts stars alphabetically, then limits recent unstarred agents to four", () => {
|
||||
const agents = [
|
||||
"Zulu",
|
||||
"Alpha",
|
||||
"Three",
|
||||
"Four",
|
||||
"Five",
|
||||
"Six",
|
||||
"Seven",
|
||||
].map((name, i) => ({ id: String(i), name }));
|
||||
expect(
|
||||
orderChatAgents(agents, ["0", "1"], ["0", "6", "5", "4", "3", "2"]).map(
|
||||
(agent) => agent.name,
|
||||
),
|
||||
).toEqual(["Alpha", "Zulu", "Seven", "Six", "Five", "Four"]);
|
||||
expect(parseRecentAgentChats('["a","a",null,1,"b"]')).toEqual(["a", "b"]);
|
||||
expect(parseRecentAgentChats("broken")).toEqual([]);
|
||||
});
|
||||
it("keeps visits personal and company scoped and moves only the visited agent", () => {
|
||||
localStorage.clear();
|
||||
recordAgentChatVisit("a", "user1", "agent1");
|
||||
recordAgentChatVisit("a", "user1", "agent2");
|
||||
recordAgentChatVisit("a", "user1", "agent1");
|
||||
recordAgentChatVisit("b", "user1", "agent3");
|
||||
recordAgentChatVisit("a", "user2", "agent4");
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user1")!),
|
||||
).toEqual(["agent1", "agent2"]);
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:b:user1")!),
|
||||
).toEqual(["agent3"]);
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user2")!),
|
||||
).toEqual(["agent4"]);
|
||||
});
|
||||
it("retains message retry identity until the server acknowledges it", () => {
|
||||
const id = chatMessageRequestId("company:user:agent", "Same draft");
|
||||
expect(chatMessageRequestId("company:user:agent", "Same draft")).toBe(id);
|
||||
expect(
|
||||
chatMessageRequestId("company:other-user:agent", "Same draft"),
|
||||
).not.toBe(id);
|
||||
acknowledgeChatMessage("company:user:agent", id);
|
||||
expect(chatMessageRequestId("company:user:agent", "Same draft")).not.toBe(
|
||||
id,
|
||||
);
|
||||
});
|
||||
it("renders a processed /new as a divider without discarding earlier messages", () => {
|
||||
const comment = {
|
||||
id: "old",
|
||||
body: "Earlier message",
|
||||
authorType: "user",
|
||||
createdAt: new Date(),
|
||||
} as IssueChatComment;
|
||||
const items = commentsToTaskChatItems([
|
||||
comment,
|
||||
{
|
||||
...comment,
|
||||
id: "reset",
|
||||
body: "/new",
|
||||
conversationSessionGeneration: 1,
|
||||
},
|
||||
{ ...comment, id: "next", body: "Fresh message" },
|
||||
]);
|
||||
expect(items.map((item) => item.kind)).toEqual([
|
||||
"message",
|
||||
"marker",
|
||||
"message",
|
||||
]);
|
||||
expect(items[1]).toMatchObject({
|
||||
label: "New session",
|
||||
variant: "session_start",
|
||||
});
|
||||
expect(
|
||||
commentsToTaskChatItems([{ ...comment, body: "/new" }])[0].kind,
|
||||
).toBe("message");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { useCallback, useSyncExternalStore } from "react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
const eventName = "paperclip:recent-agent-chats";
|
||||
const key = (company: string, user?: string | null) =>
|
||||
`paperclip.recentAgentChats:${company}:${user ?? "__local_board__"}`;
|
||||
const memory = new Map<string, string>();
|
||||
function read(storageKey: string): string {
|
||||
try {
|
||||
return (
|
||||
window.localStorage.getItem(storageKey) ?? memory.get(storageKey) ?? "[]"
|
||||
);
|
||||
} catch {
|
||||
return memory.get(storageKey) ?? "[]";
|
||||
}
|
||||
}
|
||||
export function parseRecentAgentChats(raw: string): string[] {
|
||||
try {
|
||||
const ids: unknown = JSON.parse(raw);
|
||||
return Array.isArray(ids)
|
||||
? [
|
||||
...new Set(ids.filter((id): id is string => typeof id === "string")),
|
||||
].slice(0, 50)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
export function recordAgentChatVisit(
|
||||
company: string,
|
||||
user: string | null | undefined,
|
||||
agentId: string,
|
||||
) {
|
||||
const storageKey = key(company, user);
|
||||
const value = JSON.stringify(
|
||||
[
|
||||
agentId,
|
||||
...parseRecentAgentChats(read(storageKey)).filter((id) => id !== agentId),
|
||||
].slice(0, 50),
|
||||
);
|
||||
memory.set(storageKey, value);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, value);
|
||||
} catch {
|
||||
/* In-tab navigation still works without storage. */
|
||||
}
|
||||
window.dispatchEvent(new Event(eventName));
|
||||
}
|
||||
function subscribe(callback: () => void) {
|
||||
window.addEventListener(eventName, callback);
|
||||
window.addEventListener("storage", callback);
|
||||
return () => {
|
||||
window.removeEventListener(eventName, callback);
|
||||
window.removeEventListener("storage", callback);
|
||||
};
|
||||
}
|
||||
export function useRecentAgentChats(company: string, user?: string | null) {
|
||||
const getSnapshot = useCallback(
|
||||
() => read(key(company, user)),
|
||||
[company, user],
|
||||
);
|
||||
return parseRecentAgentChats(
|
||||
useSyncExternalStore(subscribe, getSnapshot, () => "[]"),
|
||||
);
|
||||
}
|
||||
export function orderChatAgents<T extends Pick<Agent, "id" | "name">>(
|
||||
agents: T[],
|
||||
stars: string[],
|
||||
recent: string[],
|
||||
) {
|
||||
return [
|
||||
...agents
|
||||
.filter((agent) => stars.includes(agent.id))
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)),
|
||||
...recent
|
||||
.filter((id) => !stars.includes(id))
|
||||
.flatMap((id) => agents.filter((agent) => agent.id === id))
|
||||
.slice(0, 4),
|
||||
];
|
||||
}
|
||||
|
|
@ -78,10 +78,11 @@ export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[])
|
|||
}
|
||||
|
||||
export function recordRecentTask(
|
||||
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt">,
|
||||
issue: Pick<Issue, "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt" | "conversationAgentId">,
|
||||
userId: string | null | undefined,
|
||||
recordedAt = new Date(issue.updatedAt).getTime(),
|
||||
) {
|
||||
if (issue.conversationAgentId) return;
|
||||
const storageKey = getRecentTasksStorageKey(issue.companyId, userId);
|
||||
const current = readRecentTasks(storageKey, issue.companyId);
|
||||
const existing = current.find((candidate) => candidate.id === issue.id);
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function classifyShellRoute(
|
|||
|
||||
return {
|
||||
companySegments,
|
||||
isTaskDetail: root === "issues" && companySegments.length >= 2,
|
||||
isTaskDetail: (root === "issues" || root === "chats") && companySegments.length >= 2,
|
||||
builtInContextualSurface: isCompanySettings
|
||||
? "settings"
|
||||
: root === "apps" || root === "tools"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentChatsApi } from "@/api/agentChats";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useAgentChatEnabled } from "@/hooks/useAgentChatEnabled";
|
||||
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useParams } from "@/lib/router";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { TaskDetailSurface } from "./IssueDetail";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
|
||||
export function AgentChat() {
|
||||
const { agentRef = "" } = useParams<{ agentRef: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { enabled, loaded } = useAgentChatEnabled();
|
||||
const client = useQueryClient();
|
||||
const agents = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const session = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const userId =
|
||||
session.data?.user?.id ?? session.data?.session?.userId ?? null;
|
||||
const agent = agents.data?.find(
|
||||
(item) => item.id === agentRef || agentRouteRef(item) === agentRef,
|
||||
);
|
||||
const chatKey = queryKeys.agentChats.detail(selectedCompanyId, userId, agent?.id);
|
||||
const chat = useQuery({
|
||||
queryKey: chatKey,
|
||||
queryFn: () => agentChatsApi.get(selectedCompanyId!, agent!.id),
|
||||
enabled: enabled && !!agent && session.isFetched,
|
||||
});
|
||||
const creating = useRef<Promise<Issue> | null>(null);
|
||||
useEffect(() => {
|
||||
creating.current = null;
|
||||
}, [selectedCompanyId, userId, agent?.id]);
|
||||
useEffect(() => {
|
||||
if (enabled && agent && session.isFetched)
|
||||
recordAgentChatVisit(agent.companyId, userId, agent.id);
|
||||
}, [enabled, agent?.id, agent?.companyId, userId, session.isFetched]);
|
||||
const ensureIssue = useCallback(async () => {
|
||||
if (!agent || !selectedCompanyId) throw new Error("Agent not found");
|
||||
if (chat.data) return chat.data;
|
||||
const promise = (creating.current ??= agentChatsApi.ensure(
|
||||
selectedCompanyId,
|
||||
agent.id,
|
||||
));
|
||||
try {
|
||||
const issue = await promise;
|
||||
client.setQueryData(queryKeys.issues.detail(issue.id), issue);
|
||||
client.setQueryData(chatKey, issue);
|
||||
return issue;
|
||||
} catch (error) {
|
||||
creating.current = null;
|
||||
throw error;
|
||||
}
|
||||
}, [agent, selectedCompanyId, chat.data, client, userId]);
|
||||
if (!loaded || agents.isPending || session.isPending)
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Loading conversation…</p>
|
||||
);
|
||||
if (!enabled && !chat.data)
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agent Chat is disabled. Enable it in Experimental settings. Existing
|
||||
history remains available through task links.
|
||||
</p>
|
||||
);
|
||||
if (agents.error || chat.error)
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
{(agents.error ?? chat.error)?.message}
|
||||
</p>
|
||||
);
|
||||
if (!agent)
|
||||
return <p className="text-sm text-destructive">Agent not found.</p>;
|
||||
if (chat.isPending)
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">Loading conversation…</p>
|
||||
);
|
||||
return (
|
||||
<TaskDetailSurface
|
||||
key={`${agent.id}:${userId}`}
|
||||
conversation={{ agent, issue: chat.data ?? null, ensureIssue }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled";
|
||||
import { useState, useEffect, useMemo, lazy, Suspense } from "react";
|
||||
import { Link, useNavigate, useLocation } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
|
@ -192,6 +193,7 @@ function filterOrgTree(nodes: OrgNode[], tab: FilterTab, builtInAgentIds: Set<st
|
|||
export type AgentsView = "list" | "org";
|
||||
|
||||
export function Agents({ initialView = "list" }: { initialView?: AgentsView } = {}) {
|
||||
const agentChat = useAgentChatEnabled();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
|
@ -424,6 +426,7 @@ export function Agents({ initialView = "list" }: { initialView?: AgentsView } =
|
|||
metaSpacerClassName="hidden @5xl:block"
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
{agentChat.enabled && <Button variant="ghost" size="sm" onClick={event => { event.preventDefault(); event.stopPropagation(); navigate(`/chats/${agentRouteRef(agent)}`); }}>Chat</Button>}
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
{liveRunByAgent.has(agent.id) && (
|
||||
<LiveRunIndicator
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { TaskChatProjectCreatedCard } from "@/components/task-chat/TaskChatProjectCreatedCard";
|
||||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
|
||||
import { RepositoryEditor } from "@/components/RepositoryEditor";
|
||||
|
|
@ -465,6 +466,7 @@ function TaskExecutionControlsExample() {
|
|||
onCancel={() => setDialogMode("cancel")} onRestore={() => setDialogMode("restore")} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{running ? "Running: type to switch Stop to Send." : "Paused: resume from the menu."}</p>
|
||||
<TaskChatProjectCreatedCard item={{ id: "design-project", kind: "project_created", projectId: "example-project", name: "Onboarding improvements", description: "Help new teams reach their first useful result.", timestamp: "2026-09-11T00:00:00Z", repositories: [{ id: "1", name: "paperclipai/paperclip", url: "https://github.com/paperclipai/paperclip" }] }} />
|
||||
{!running ? <TaskChatMarker item={{ id: "design-cancelled", kind: "marker", variant: "interrupted", tone: "neutral", label: "Run cancelled", detail: "The run was cancelled before returning an answer.", collapsible: true }} /> : null}
|
||||
<TaskChatComposer pause={!running ? { scope: "subtree", onResume: () => setDialogMode("resume") } : null} onAdd={async () => {}} workMode="standard" stopScope="subtree" onStop={running ? async () => setRunning(false) : undefined} />
|
||||
<TaskTreeControlDialog open={dialogMode !== null} onOpenChange={(open) => { if (!open) setDialogMode(null); }}
|
||||
|
|
@ -1632,6 +1634,11 @@ export function DesignGuide() {
|
|||
{/* ============================================================ */}
|
||||
<Section title="Navigation Patterns">
|
||||
<SubSection title="Sidebar nav items">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Layout accepts sidebarSections to compose additional SidebarSection groups inside the shared sidebar.
|
||||
Use SidebarNavItem for each row, with sibling action buttons for starring or menus.
|
||||
Starred agent conversations precede recent conversations without a divider. Stars appear on hover or keyboard focus. Task breadcrumbs support leading identity and trailing actions beside the label, including single-item task headers; see the Agent chat Storybook.
|
||||
</p>
|
||||
<Card className="block w-60 p-3 space-y-0.5">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium bg-accent text-accent-foreground">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -312,6 +312,17 @@ export function InstanceExperimentalSettings() {
|
|||
ariaLabel="Toggle cases experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Agent Chat"
|
||||
description="Talk to each agent in one ongoing conversation. Clarify goals and create tasks for execution."
|
||||
footnote="Turning this off preserves conversations and lets active runs finish, but prevents new messages."
|
||||
checked={experimentalQuery.data?.enableAgentChat ?? false}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableAgentChat: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableAgentChat"
|
||||
managed={managedKeys.enableAgentChat}
|
||||
ariaLabel="Toggle agent chat experimental setting"
|
||||
/>
|
||||
<ExperimentalToggleCard
|
||||
title="Chat connectors"
|
||||
description="Connect agents to Slack, GitHub, Discord, Microsoft Teams, and Telegram conversations."
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
canBoardManageRuntime,
|
||||
canBoardResolveRecoveryAction,
|
||||
IssueDetail,
|
||||
TaskDetailSurface,
|
||||
readRecoveryReconcileWorkspaceId,
|
||||
shouldScrollIssueDetailToTopOnNavigation,
|
||||
} from "./IssueDetail";
|
||||
|
|
@ -569,6 +570,7 @@ vi.mock("../components/ApprovalCard", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../components/Identity", () => ({
|
||||
deriveInitials: (name: string) => name.slice(0, 2),
|
||||
Identity: ({ name, shape }: { name: string; shape?: string }) => (
|
||||
<span data-shape={shape ?? "circle"}>{name}</span>
|
||||
),
|
||||
|
|
@ -1412,6 +1414,50 @@ describe("IssueDetail", () => {
|
|||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps an existing conversation on its agent-addressed route", async () => {
|
||||
const agent = createAgent();
|
||||
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
|
||||
mockIssuesApi.get.mockResolvedValue(canonical);
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={queryClient}><TaskDetailSurface conversation={{ agent, issue: canonical, ensureIssue: async () => canonical }} /></QueryClientProvider>);
|
||||
});
|
||||
await flushReact();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect(mockIssuesApi.markRead).toHaveBeenCalledWith(canonical.id);
|
||||
});
|
||||
|
||||
it.each(["message", "attachment"])("creates an unused conversation only for the first %s and updates its canonical cache", async (kind) => {
|
||||
mockIssuesApi.markRead.mockClear();
|
||||
const agent = createAgent();
|
||||
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review" });
|
||||
const ensureIssue = vi.fn().mockResolvedValue(canonical);
|
||||
const invalidate = vi.spyOn(queryClient, "invalidateQueries");
|
||||
mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Clarify this goal" }));
|
||||
mockIssuesApi.uploadAttachment.mockResolvedValue(createAttachment({ id: "first-upload" }));
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={queryClient}><TaskDetailSurface conversation={{ agent, issue: null, ensureIssue }} /></QueryClientProvider>);
|
||||
});
|
||||
await flushReact();
|
||||
expect(ensureIssue).not.toHaveBeenCalled();
|
||||
expect(mockIssuesApi.markRead).not.toHaveBeenCalled();
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
onAdd: (body: string) => Promise<void>;
|
||||
onAttachImage: (file: File) => Promise<IssueAttachment>;
|
||||
};
|
||||
if (kind === "message") {
|
||||
await act(async () => { await props.onAdd("Clarify this goal"); });
|
||||
expect(mockIssuesApi.addComment).toHaveBeenCalledWith(canonical.id, "Clarify this goal", undefined, undefined, undefined, expect.any(String));
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.comments(canonical.id) });
|
||||
} else {
|
||||
const file = new File(["image"], "first.png", { type: "image/png" });
|
||||
await act(async () => { await props.onAttachImage(file); });
|
||||
expect(mockIssuesApi.uploadAttachment).toHaveBeenCalledWith(canonical.companyId, canonical.id, file);
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.attachments(canonical.id) });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: queryKeys.issues.detail(canonical.id) });
|
||||
}
|
||||
expect(ensureIssue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens artifact cards in the shared gallery at the selected image without duplicating attachments", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockIssuesApi.listAttachments.mockResolvedValue([
|
||||
|
|
@ -1607,6 +1653,7 @@ describe("IssueDetail", () => {
|
|||
undefined,
|
||||
undefined,
|
||||
[id],
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mockIssuesApi.update).not.toHaveBeenCalled();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import { acknowledgeChatMessage, chatMessageRequestId } from "@/lib/chat-message-request";
|
||||
import { agentChatDraft } from "@/lib/agent-chat-draft";
|
||||
import { Settings as ChatSettings } from "lucide-react";
|
||||
import { agentDetailHref } from "./agent-detail-navigation";
|
||||
import { deriveInitials } from "@/components/Identity";
|
||||
import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
|
||||
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
|
||||
import { EmailThreadProvider } from "../components/EmailMessageCard";
|
||||
|
|
@ -931,14 +936,14 @@ function IssueChatSkeleton() {
|
|||
);
|
||||
}
|
||||
|
||||
function useTaskDetailInterfaceMode() {
|
||||
function useTaskDetailInterfaceMode(conversationMode = false) {
|
||||
const {
|
||||
enabled: classicTaskInterfacePreferenceEnabled,
|
||||
loaded: classicTaskInterfaceLoaded,
|
||||
} = useClassicTaskInterfaceEnabled();
|
||||
const { enabled: streamlinedUiEnabled, loaded: streamlinedUiLoaded } =
|
||||
useStreamlinedUiEnabled();
|
||||
const classicTaskInterfaceEnabled = classicTaskInterfacePreferenceEnabled;
|
||||
const classicTaskInterfaceEnabled = classicTaskInterfacePreferenceEnabled && !conversationMode;
|
||||
const taskChatShellEnabled = !classicTaskInterfaceEnabled;
|
||||
|
||||
return {
|
||||
|
|
@ -1252,6 +1257,7 @@ type IssueDetailChatTabProps = {
|
|||
currentAssigneeValue: string;
|
||||
suggestedAssigneeValue: string;
|
||||
mentions: MentionOption[];
|
||||
conversationMode?: boolean;
|
||||
composerPause?: TaskComposerPause | null;
|
||||
composerDisabledReason: string | null;
|
||||
composerHint: string | null;
|
||||
|
|
@ -1374,6 +1380,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
currentAssigneeValue,
|
||||
suggestedAssigneeValue,
|
||||
mentions,
|
||||
conversationMode,
|
||||
composerPause,
|
||||
composerDisabledReason,
|
||||
composerHint,
|
||||
|
|
@ -1412,7 +1419,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
// Preserve master's Classic Task Interface seam: Streamlined UI changes the
|
||||
// TaskChatThread presentation but never swaps it for IssueChatThread.
|
||||
const { classicTaskInterfaceEnabled, streamlinedTaskDetailEnabled } =
|
||||
useTaskDetailInterfaceMode();
|
||||
useTaskDetailInterfaceMode(!!conversationMode);
|
||||
const ThreadComponent = classicTaskInterfaceEnabled
|
||||
? IssueChatThread
|
||||
: TaskChatThread;
|
||||
|
|
@ -1428,6 +1435,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
} = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
queryFn: () => activityApi.forIssue(issueId),
|
||||
enabled: !!issueId,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
|
||||
});
|
||||
const {
|
||||
|
|
@ -1438,6 +1446,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
} = useQuery({
|
||||
queryKey: queryKeys.issues.liveRuns(issueId),
|
||||
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
||||
enabled: !!issueId,
|
||||
refetchInterval: 1000,
|
||||
placeholderData:
|
||||
keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
||||
|
|
@ -1523,6 +1532,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
} = useQuery({
|
||||
queryKey: queryKeys.issues.runs(issueId),
|
||||
queryFn: () => activityApi.runsForIssue(issueId),
|
||||
enabled: !!issueId,
|
||||
refetchInterval:
|
||||
hasLiveRuns || issueStatus === "in_progress" ? 1000 : false,
|
||||
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
||||
|
|
@ -2294,12 +2304,13 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
<EmailThreadProvider companyId={companyId} issueId={issueId}>
|
||||
<ThreadComponent
|
||||
key={issueId}
|
||||
initialHistoryPending={
|
||||
{...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity } : {})}
|
||||
initialHistoryPending={!!issueId && (
|
||||
initialHistoryPending ||
|
||||
commentsInitialLoading ||
|
||||
activityPending ||
|
||||
linkedRunsPending ||
|
||||
!runtimeSelectionKnown
|
||||
!runtimeSelectionKnown)
|
||||
}
|
||||
initialHistoryError={
|
||||
initialHistoryError ||
|
||||
|
|
@ -2381,7 +2392,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
draftKey={draftKey}
|
||||
enableReassign
|
||||
conversationMode={conversationMode}
|
||||
enableReassign={!conversationMode}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
|
|
@ -2821,11 +2833,17 @@ function IssueDetailActivityTab({
|
|||
);
|
||||
}
|
||||
|
||||
export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"] }) {
|
||||
const { issueId, companyPrefix } = useParams<{
|
||||
issueId: string;
|
||||
companyPrefix: string;
|
||||
}>();
|
||||
export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"] }) { return <TaskDetailSurface tasksTab={tasksTab} />; }
|
||||
|
||||
/** One controller and surface for both task URLs and agent conversations. */
|
||||
export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskSidePanelProps["tasksTab"]; conversation?: {
|
||||
agent: Agent; issue: Issue | null; ensureIssue: () => Promise<Issue>;
|
||||
} }) {
|
||||
const { issueId: routeIssueId, companyPrefix } = useParams<{ issueId: string; companyPrefix: string }>();
|
||||
const issueId = conversation ? conversation.issue?.id : routeIssueId;
|
||||
const [draftWorkMode, setDraftWorkMode] = useState<IssueWorkMode>("standard");
|
||||
const draftIssue = useMemo(() => conversation ? agentChatDraft(conversation.agent, draftWorkMode) : undefined, [conversation?.agent, draftWorkMode]);
|
||||
const messageRequestIds = useRef(new Map<string, string>());
|
||||
const { companies, selectedCompanyId } = useCompany();
|
||||
// Classic Task Interface remains the sole task-chat-vs-pre-chat switch from
|
||||
// master. Streamlined UI only layers the new task-detail presentation onto
|
||||
|
|
@ -2836,7 +2854,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
streamlinedTaskDetailEnabled,
|
||||
streamlinedUiEnabled,
|
||||
loaded: taskInterfaceSettingsLoaded,
|
||||
} = useTaskDetailInterfaceMode();
|
||||
} = useTaskDetailInterfaceMode(!!conversation);
|
||||
// Chat-style: the page wrapper spans the full center pane so the thread's
|
||||
// scroll viewport (and its scrollbar) reaches the properties-pane border;
|
||||
// every non-thread section re-centers itself at the 60rem shell cap instead.
|
||||
|
|
@ -2938,7 +2956,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
);
|
||||
|
||||
const {
|
||||
data: issue,
|
||||
data: queriedIssue,
|
||||
isLoading,
|
||||
isPlaceholderData,
|
||||
error,
|
||||
|
|
@ -2953,6 +2971,13 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
}),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
const issue = queriedIssue ?? conversation?.issue ?? draftIssue;
|
||||
const resolveWritableIssueId = async () => {
|
||||
if (!conversation) return issueId!;
|
||||
const resolved = await conversation.ensureIssue();
|
||||
if (!conversation.issue && draftWorkMode !== resolved.workMode) await issuesApi.update(resolved.id, { workMode: draftWorkMode });
|
||||
return resolved.id;
|
||||
};
|
||||
// A cached header seed can paint during navigation, but must not redirect
|
||||
// or upload against the previous task while the requested task is loading.
|
||||
const loadedIssue =
|
||||
|
|
@ -2967,14 +2992,14 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
const loadedIssueCompany = loadedIssue
|
||||
? companies.find((company) => company.id === loadedIssue.companyId)
|
||||
: undefined;
|
||||
const taskRouteReady = Boolean(
|
||||
const taskRouteReady = Boolean(conversation || (
|
||||
loadedIssue &&
|
||||
issueId === (loadedIssue.identifier ?? loadedIssue.id) &&
|
||||
(!loadedIssueCompany || companyPrefix === loadedIssueCompany.issuePrefix) &&
|
||||
!hasLegacyIssueDetailQuery(location.search),
|
||||
);
|
||||
!hasLegacyIssueDetailQuery(location.search)
|
||||
));
|
||||
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
||||
const externalObjectsState = useIssueExternalObjects(issue?.id ?? null);
|
||||
const externalObjectsState = useIssueExternalObjects(conversation && !conversation.issue ? null : issue?.id ?? null);
|
||||
// A closed isolated workspace no longer blocks the composer. The server reopens
|
||||
// the workspace when the next comment or resume arrives, so the composer stays
|
||||
// enabled and a hint tells the user what happens.
|
||||
|
|
@ -3193,7 +3218,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
descendantOf: issue!.id,
|
||||
includeBlockedBy: true,
|
||||
}),
|
||||
enabled: !!resolvedCompanyId && !!issue?.id,
|
||||
enabled: !!resolvedCompanyId && !!issue?.id && !issue.id.startsWith("chat:"),
|
||||
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(
|
||||
issue?.id ?? "pending",
|
||||
),
|
||||
|
|
@ -4372,8 +4397,16 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
reopen?: boolean;
|
||||
interrupt?: boolean;
|
||||
attachmentIds?: string[];
|
||||
}) =>
|
||||
issuesApi.addComment(issueId!, body, reopen, interrupt, attachmentIds),
|
||||
}) => {
|
||||
const chatScope = issue?.conversationAgentId ? `${issue.companyId}:${currentUserId}:${issue.conversationAgentId}` : null;
|
||||
const requestId = chatScope ? chatMessageRequestId(chatScope, body) : messageRequestIds.current.get(body) ?? crypto.randomUUID();
|
||||
messageRequestIds.current.set(body, requestId);
|
||||
return resolveWritableIssueId().then(id => issuesApi.addComment(id, body, reopen, interrupt, attachmentIds, requestId)).then(comment => {
|
||||
messageRequestIds.current.delete(body);
|
||||
if (chatScope) acknowledgeChatMessage(chatScope, requestId);
|
||||
return comment;
|
||||
});
|
||||
},
|
||||
onMutate: async ({ body, reopen, interrupt }) => {
|
||||
// Start cache cancellation immediately but do not put it in front of the
|
||||
// optimistic echo. The new-runner startup placeholder must paint in the
|
||||
|
|
@ -4438,7 +4471,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
),
|
||||
);
|
||||
try {
|
||||
await issuesApi.cancelComment(issueId!, comment.id);
|
||||
await issuesApi.cancelComment(comment.issueId, comment.id);
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueThreadLazily();
|
||||
invalidateIssueCollections();
|
||||
|
|
@ -4461,14 +4494,14 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
return next;
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.queuedComments(issueId!),
|
||||
queryKey: queryKeys.issues.queuedComments(issueId ?? comment.issueId),
|
||||
});
|
||||
}
|
||||
if (context?.optimisticCommentId) {
|
||||
commentRenderKeys.current.set(comment.id, context.optimisticCommentId);
|
||||
}
|
||||
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
||||
queryKeys.issues.comments(issueId!),
|
||||
queryKeys.issues.comments(issueId ?? comment.issueId),
|
||||
(current) =>
|
||||
current
|
||||
? {
|
||||
|
|
@ -4518,7 +4551,8 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: (_result, _error, variables) => {
|
||||
onSettled: (result, _error, variables) => {
|
||||
if (result && !issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(result.issueId) });
|
||||
if (_error) void queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state"] });
|
||||
invalidateIssueThreadLazily();
|
||||
// Binding happens when the comment saves, after the upload's earlier
|
||||
|
|
@ -5212,6 +5246,9 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
|
||||
const uploadAttachment = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (conversation) {
|
||||
return issuesApi.uploadAttachment(conversation.agent.companyId, await resolveWritableIssueId(), file);
|
||||
}
|
||||
if (!loadedIssue)
|
||||
throw new Error("Task details are still loading. Please try again.");
|
||||
return issuesApi.uploadAttachment(
|
||||
|
|
@ -5220,12 +5257,13 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
file,
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (result) => {
|
||||
setAttachmentError(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.attachments(issueId!),
|
||||
queryKey: queryKeys.issues.attachments(issueId ?? result.issueId),
|
||||
});
|
||||
invalidateIssueDetail();
|
||||
if (!issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(result.issueId) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setAttachmentError(err instanceof Error ? err.message : "Upload failed");
|
||||
|
|
@ -5241,18 +5279,19 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
const body = await file.text();
|
||||
const inferredTitle = titleizeFilename(baseName);
|
||||
const nextTitle = existing?.title ?? inferredTitle ?? null;
|
||||
return issuesApi.upsertDocument(issueId!, key, {
|
||||
return issuesApi.upsertDocument(await resolveWritableIssueId(), key, {
|
||||
title: key === "plan" ? null : nextTitle,
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: existing?.latestRevisionId ?? null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (result) => {
|
||||
setAttachmentError(null);
|
||||
invalidateIssueDetail();
|
||||
if (!issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(result.issueId) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.documents(issueId!),
|
||||
queryKey: queryKeys.issues.documents(issueId ?? result.issueId),
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
|
|
@ -5347,7 +5386,18 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
},
|
||||
});
|
||||
|
||||
const conversationAgent = conversation?.agent ?? agents?.find(agent => agent.id === issue?.conversationAgentId);
|
||||
useEffect(() => {
|
||||
if (conversationAgent) {
|
||||
setBreadcrumbs([{
|
||||
label: conversationAgent.name,
|
||||
leading: <Avatar className="size-6 shrink-0"><AvatarFallback>{deriveInitials(conversationAgent.name)}</AvatarFallback></Avatar>,
|
||||
leadingKey: `agent:${conversationAgent.id}`,
|
||||
trailing: <Button variant="ghost" size="icon-xs" asChild aria-label={`Configure ${conversationAgent.name}`}><Link to={agentDetailHref(conversationAgent.id, "runtime")}><ChatSettings /></Link></Button>,
|
||||
trailingKey: `configure:${conversationAgent.id}`,
|
||||
}]);
|
||||
return;
|
||||
}
|
||||
setBreadcrumbs([
|
||||
sourceBreadcrumb,
|
||||
{
|
||||
|
|
@ -5360,6 +5410,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
},
|
||||
]);
|
||||
}, [
|
||||
conversationAgent,
|
||||
breadcrumbTitle,
|
||||
breadcrumbIdentifier,
|
||||
hasLiveRuns,
|
||||
|
|
@ -5448,7 +5499,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
// Resolve external UUID links and wrong-prefix task links from the loaded
|
||||
// task's company, not the organization that happened to be selected first.
|
||||
useEffect(() => {
|
||||
if (!loadedIssue) return;
|
||||
if (conversation || !loadedIssue) return;
|
||||
const nextState = resolvedIssueDetailState ?? location.state;
|
||||
const taskCompany = loadedIssueCompany;
|
||||
const canonicalRef = loadedIssue.identifier ?? loadedIssue.id;
|
||||
|
|
@ -5477,6 +5528,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
);
|
||||
}
|
||||
}, [
|
||||
conversation,
|
||||
loadedIssue,
|
||||
loadedIssueCompany,
|
||||
companyPrefix,
|
||||
|
|
@ -5489,7 +5541,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.id) return;
|
||||
if (!issueId || !issue?.id) return;
|
||||
if (lastMarkedReadIssueIdRef.current === issue.id) return;
|
||||
lastMarkedReadIssueIdRef.current = issue.id;
|
||||
markIssueRead.mutate(issue.id);
|
||||
|
|
@ -5585,7 +5637,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!panelIssue || suppressPanelUntilPlan) {
|
||||
if (!panelIssue || suppressPanelUntilPlan || (conversation && !conversation.issue)) {
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
|
|
@ -6836,7 +6888,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
/>
|
||||
);
|
||||
|
||||
const issueHeaderBlock = (
|
||||
const issueHeaderBlock = issue.conversationAgentId ? null : (
|
||||
<div
|
||||
data-testid="issue-detail-header"
|
||||
className={cn(
|
||||
|
|
@ -7329,7 +7381,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
) : undefined;
|
||||
|
||||
return (
|
||||
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
|
||||
<FileViewerProvider issueId={conversation && !conversation.issue ? "" : issue.id} enabled={fileViewerEnabled}>
|
||||
<IssueGalleryContext.Provider value={openIssueGallery}>
|
||||
<div
|
||||
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
|
||||
|
|
@ -7684,7 +7736,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
// Suppress the seeded-description bubble for the onboarding first
|
||||
// task: its description is agent instructions, not something the
|
||||
// user typed. The user lands on a seeded agent greeting instead.
|
||||
taskChatShellEnabled &&
|
||||
taskChatShellEnabled && !issue.conversationAgentId &&
|
||||
issue.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND
|
||||
? {
|
||||
description: issue.description ?? "",
|
||||
|
|
@ -7714,7 +7766,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
}
|
||||
: undefined
|
||||
}
|
||||
issueId={issue.id}
|
||||
issueId={conversation && !conversation.issue ? "" : issue.id}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId ?? null}
|
||||
issueStatus={issue.status}
|
||||
|
|
@ -7806,11 +7858,12 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
draftKey={conversationAgent ? `paperclip:agent-chat-draft:${issue.companyId}:${currentUserId}:${conversationAgent.id}` : `paperclip:issue-comment-draft:${issue.id}`}
|
||||
reassignOptions={commentReassignOptions}
|
||||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
conversationMode={!!issue.conversationAgentId}
|
||||
composerPause={activePauseHold ? {
|
||||
scope: activePauseHold.isRoot && childIssues.length === 0 ? "leaf" : "subtree",
|
||||
pending: executeTreeControl.isPending && executeTreeControl.variables?.mode === "resume",
|
||||
|
|
@ -7822,7 +7875,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
} : undefined,
|
||||
resumeHref: !activePauseHold.isRoot ? createIssueDetailPath(activePauseHoldRoot?.identifier ?? activePauseHold.rootIssueId) : undefined,
|
||||
} : null}
|
||||
composerDisabledReason={treeControlStatePending ? "Checking task status…" : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
|
||||
composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : issueId && treeControlStatePending ? "Checking task status…" : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
|
||||
composerHint={composerHint}
|
||||
queuedCommentReason={queuedCommentReason}
|
||||
onVote={handleCommentVote}
|
||||
|
|
@ -7867,6 +7920,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
|
|||
const currentMode: IssueWorkMode =
|
||||
issue.workMode ?? "standard";
|
||||
if (currentMode === nextMode) return;
|
||||
if (conversation && !conversation.issue) { setDraftWorkMode(nextMode); return; }
|
||||
return updateIssue
|
||||
.mutateAsync({ workMode: nextMode })
|
||||
.then(() => undefined);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,555 @@
|
|||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { agentRouteRef } from "@/lib/utils";
|
||||
import { recordAgentChatVisit } from "@/lib/recent-agent-chats";
|
||||
import { AgentDetail } from "@/pages/AgentDetail";
|
||||
import { AgentChat } from "@/pages/AgentChat";
|
||||
import { IssueDetail } from "@/pages/IssueDetail";
|
||||
import { Agents, AGENT_FILTER_TABS } from "@/pages/Agents";
|
||||
import { Layout } from "@/components/Layout";
|
||||
import { usePanel } from "@/context/PanelContext";
|
||||
import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { Routes, Route, useNavigate, useLocation } from "@/lib/router";
|
||||
import type {
|
||||
IssueChatComment,
|
||||
IssueChatLinkedRun,
|
||||
} from "@/lib/issue-chat-messages";
|
||||
import {
|
||||
taskPanelArtifactsTab,
|
||||
taskPanelDocumentTab,
|
||||
taskPanelPropertiesTab,
|
||||
taskPanelSubtasksTab,
|
||||
writeTaskSidePanelState,
|
||||
} from "@/lib/task-side-panel-state";
|
||||
import {
|
||||
storybookAgents,
|
||||
storybookIssues,
|
||||
storybookIssueDocuments,
|
||||
} from "../../fixtures/paperclipData";
|
||||
import { chatAgents, chatIdentifier } from "./AgentChatSidebar";
|
||||
|
||||
const agent = storybookAgents.find((agent) => agent.id === "agent-codex")!;
|
||||
const issue = {
|
||||
...storybookIssues[0],
|
||||
id: "agent-chat-shared-task",
|
||||
identifier: "PAP-241",
|
||||
title: "Chat with CodexCoder",
|
||||
description: "",
|
||||
status: "in_review" as const,
|
||||
executionRunId: null,
|
||||
checkoutRunId: null,
|
||||
executionLockedAt: null,
|
||||
assigneeAgentId: agent.id,
|
||||
parentId: null,
|
||||
blockedBy: [],
|
||||
blocks: [],
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
currentExecutionWorkspace: null,
|
||||
};
|
||||
const child = {
|
||||
...storybookIssues[0],
|
||||
id: "agent-chat-child",
|
||||
identifier: "PAP-248",
|
||||
title: "Improve the first agent handoff",
|
||||
parentId: issue.id,
|
||||
status: "todo" as const,
|
||||
};
|
||||
const plan = {
|
||||
...storybookIssueDocuments[0],
|
||||
issueId: issue.id,
|
||||
title: "Launch plan",
|
||||
createdAt: new Date("2026-09-10T15:42:15Z"),
|
||||
updatedAt: new Date("2026-09-10T15:42:20Z"),
|
||||
body: "# A smaller, clearer launch\n\nFocus on the first useful result.\n\n## Listen\nReview the five most recent onboarding conversations and record where people hesitate.\n\n## Improve the first handoff\nGive the first agent one small, useful task. Show its output where the user can open it.\n\n## Invite a small group\nShare the improved flow with ten teams and ask whether they reached a useful result without help.",
|
||||
};
|
||||
const notes = {
|
||||
...storybookIssueDocuments[1],
|
||||
issueId: issue.id,
|
||||
title: "Onboarding notes",
|
||||
createdAt: new Date("2026-09-10T15:42:10Z"),
|
||||
updatedAt: new Date("2026-09-10T15:42:12Z"),
|
||||
body: "# Onboarding notes\n\nPeople understand hiring an agent quickly. The uncertainty starts with what to ask it to do first.\n\n- Give one concrete starting point.\n- Keep the conversation available after work finishes.\n- Put the result beside the conversation.",
|
||||
};
|
||||
const runId = "agent-chat-shared-run";
|
||||
const run: IssueChatLinkedRun = {
|
||||
runId,
|
||||
status: "succeeded",
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
adapterType: "codex_local",
|
||||
createdAt: new Date("2026-09-10T15:42:00Z"),
|
||||
startedAt: new Date("2026-09-10T15:42:00Z"),
|
||||
finishedAt: new Date("2026-09-10T15:43:00Z"),
|
||||
hasStoredOutput: true,
|
||||
};
|
||||
const logItems = [
|
||||
{
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "thinking-1",
|
||||
type: "reasoning",
|
||||
text: "I’ll review the onboarding notes and separate the launch discussion from the implementation task.",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "item.started",
|
||||
item: {
|
||||
id: "read-notes",
|
||||
type: "command_execution",
|
||||
command: "cat onboarding-notes.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "read-notes",
|
||||
type: "command_execution",
|
||||
command: "cat onboarding-notes.md",
|
||||
aggregated_output:
|
||||
"Users need a clear first task and an inspectable result.",
|
||||
status: "completed",
|
||||
exit_code: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "item.started",
|
||||
item: {
|
||||
id: "save-plan",
|
||||
type: "command_execution",
|
||||
command: "paperclip documents update PAP-241 plan",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "item.completed",
|
||||
item: {
|
||||
id: "save-plan",
|
||||
type: "command_execution",
|
||||
command: "paperclip documents update PAP-241 plan",
|
||||
aggregated_output: "Saved launch plan revision 3.",
|
||||
status: "completed",
|
||||
exit_code: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
function runLogContent() {
|
||||
return (
|
||||
logItems
|
||||
.map((item, index) =>
|
||||
JSON.stringify({
|
||||
ts: new Date(
|
||||
Date.parse("2026-09-10T15:42:00Z") + index * 5000,
|
||||
).toISOString(),
|
||||
stream: "stdout",
|
||||
seq: index + 1,
|
||||
chunk: JSON.stringify(item) + "\n",
|
||||
}),
|
||||
)
|
||||
.join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
function comment(
|
||||
id: string,
|
||||
body: string,
|
||||
agentReply = false,
|
||||
): IssueChatComment {
|
||||
const createdAt = new Date(
|
||||
agentReply ? "2026-09-10T15:43:00Z" : "2026-09-10T15:41:00Z",
|
||||
);
|
||||
return {
|
||||
id,
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
body,
|
||||
authorType: agentReply ? "agent" : "user",
|
||||
authorAgentId: agentReply ? agent.id : null,
|
||||
authorUserId: agentReply ? null : "user-board",
|
||||
runId: agentReply ? runId : null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
const comments = [
|
||||
comment(
|
||||
"chat-request",
|
||||
"I've been thinking about the launch. Are we trying to do too much at once? Help me work through it, and create a task for the implementation.",
|
||||
),
|
||||
comment(
|
||||
"chat-response",
|
||||
"I’d focus on the first useful result: give an agent one clear task, then make its output easy to find.\n\nI saved the **launch plan** and **onboarding notes** alongside this conversation. **PAP-248** tracks the first-handoff implementation separately.\n\nWe can keep thinking through the launch here. What's the first thing you want a new user to understand?",
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
type Scenario =
|
||||
| "returning"
|
||||
| "empty"
|
||||
| "working"
|
||||
| "paused"
|
||||
| "error"
|
||||
| "long"
|
||||
| "new-session"
|
||||
| "disabled"
|
||||
| "project-reused"
|
||||
| "project-created"
|
||||
| "project-multi-repo"
|
||||
| "project-no-repo"
|
||||
| "project-failed";
|
||||
export interface AgentChatPrototypeProps {
|
||||
scenario?: Scenario;
|
||||
contextInitiallyOpen?: boolean;
|
||||
taskComparison?: boolean;
|
||||
}
|
||||
|
||||
/** Production pages with an in-memory API. No alternate chat controller. */
|
||||
export function AgentChatPrototype({
|
||||
scenario = "returning",
|
||||
contextInitiallyOpen = true,
|
||||
taskComparison = false,
|
||||
}: AgentChatPrototypeProps) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const { setPanelVisible } = usePanel();
|
||||
useEffect(() => {
|
||||
setPanelVisible(contextInitiallyOpen);
|
||||
}, [contextInitiallyOpen, setPanelVisible]);
|
||||
useLayoutEffect(() => {
|
||||
const originalFetch = window.fetch;
|
||||
const chats = new Map<
|
||||
string,
|
||||
typeof issue & {
|
||||
conversationAgentId?: string | null;
|
||||
conversationUserId?: string | null;
|
||||
conversationState?: "waiting";
|
||||
}
|
||||
>();
|
||||
const messages = new Map<string, IssueChatComment[]>();
|
||||
const members = {
|
||||
projectMemberships: {},
|
||||
agentMemberships: {},
|
||||
starredProjectIds: [],
|
||||
starredAgentIds: ["agent-cto"],
|
||||
starredDocumentIds: [],
|
||||
projectStarredAt: {},
|
||||
agentStarredAt: {},
|
||||
documentStarredAt: {},
|
||||
updatedAt: null,
|
||||
};
|
||||
let failSend = scenario === "error";
|
||||
let active = scenario === "working";
|
||||
const fixtureAgents = chatAgents.map((a) => ({
|
||||
...a,
|
||||
status:
|
||||
scenario === "paused" && a.id === agent.id
|
||||
? ("paused" as const)
|
||||
: a.status,
|
||||
}));
|
||||
for (const a of fixtureAgents) {
|
||||
const task = {
|
||||
...issue,
|
||||
id: a.id === agent.id ? issue.id : `chat-task-${a.id}`,
|
||||
identifier: chatIdentifier(a.id),
|
||||
assigneeAgentId: a.id,
|
||||
title: `Chat with ${a.name}`,
|
||||
conversationAgentId: taskComparison ? null : a.id,
|
||||
conversationUserId: taskComparison ? null : "user-board",
|
||||
conversationState: "waiting" as const,
|
||||
};
|
||||
if (scenario !== "empty" || a.id !== agent.id) chats.set(a.id, task);
|
||||
let history =
|
||||
a.id === agent.id && scenario !== "empty"
|
||||
? scenario === "working"
|
||||
? comments.slice(0, 1)
|
||||
: [...comments]
|
||||
: [];
|
||||
if (scenario === "long" && a.id === agent.id)
|
||||
history = [
|
||||
...Array.from({ length: 24 }, (_, i) => ({
|
||||
...comment(
|
||||
`history-${i}`,
|
||||
i % 2
|
||||
? "Capture where users hesitate in the onboarding notes."
|
||||
: "What should we learn from onboarding?",
|
||||
i % 2 === 1,
|
||||
),
|
||||
runId: null,
|
||||
createdAt: new Date(Date.parse("2026-09-09T12:00:00Z") + i * 60000),
|
||||
})),
|
||||
...history,
|
||||
];
|
||||
if (scenario === "new-session" && a.id === agent.id)
|
||||
history.push({
|
||||
...comment("session-boundary", "/new"),
|
||||
conversationSessionGeneration: 1,
|
||||
createdAt: new Date("2026-09-10T15:45:00Z"),
|
||||
});
|
||||
if (scenario.startsWith("project-") && a.id === agent.id) history[history.length - 1] = {
|
||||
...history[history.length - 1], body: scenario === "project-failed"
|
||||
? "Project creation failed because repository access is unavailable. I kept the plan here; no execution task was created."
|
||||
: scenario === "project-reused"
|
||||
? "I copied the relevant plan to [PAP-248](/PAP/issues/PAP-248) in the existing Launch project and assigned CodexCoder. The original plan remains here."
|
||||
: "I saved the plan here and copied it to [PAP-248](/PAP/issues/PAP-248) in the new project. The assigned task can now begin; we can continue the discussion here.",
|
||||
};
|
||||
messages.set(task.id, history);
|
||||
writeTaskSidePanelState("user-board", task.companyId, task.id, {
|
||||
state: {
|
||||
tabs: taskComparison
|
||||
? [taskPanelPropertiesTab()]
|
||||
: [
|
||||
taskPanelDocumentTab("plan", "Launch plan"),
|
||||
taskPanelArtifactsTab(),
|
||||
taskPanelSubtasksTab(),
|
||||
],
|
||||
activeTabId: taskComparison ? "properties" : "document:plan",
|
||||
},
|
||||
launcherOpen: false,
|
||||
userInteracted: true,
|
||||
autoPlanHandled: true,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
for (const id of ["chat-design", "agent-qa", "agent-codex"])
|
||||
recordAgentChatVisit(issue.companyId, "user-board", id);
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url,
|
||||
window.location.origin,
|
||||
);
|
||||
const path = url.pathname;
|
||||
if (!path.startsWith("/api/")) return originalFetch(input, init);
|
||||
const method = (
|
||||
init?.method ?? (input instanceof Request ? input.method : "GET")
|
||||
).toUpperCase();
|
||||
const body =
|
||||
init?.body && typeof init.body === "string"
|
||||
? JSON.parse(init.body)
|
||||
: {};
|
||||
const chatRef = path.match(/\/chats\/([^/]+)$/)?.[1];
|
||||
if (chatRef) {
|
||||
const a = fixtureAgents.find(
|
||||
(a) => a.id === chatRef || agentRouteRef(a) === chatRef,
|
||||
);
|
||||
if (!a)
|
||||
return Response.json({ error: "Agent not found" }, { status: 404 });
|
||||
if (method === "POST" && !chats.has(a.id))
|
||||
chats.set(a.id, {
|
||||
...issue,
|
||||
conversationAgentId: a.id,
|
||||
conversationUserId: "user-board",
|
||||
conversationState: "waiting",
|
||||
id: issue.id,
|
||||
});
|
||||
return Response.json(chats.get(a.id) ?? null);
|
||||
}
|
||||
const taskRef = path.match(/\/issues\/([^/]+)/)?.[1];
|
||||
const task =
|
||||
[...chats.values()].find(
|
||||
(t) => t.id === taskRef || t.identifier === taskRef,
|
||||
) ?? issue;
|
||||
if (path.endsWith("/resource-memberships/me"))
|
||||
return Response.json(members);
|
||||
if (/resource-memberships\/me\/agents\//.test(path) && method === "PUT") {
|
||||
const id = path.split("/").at(-1)!;
|
||||
members.starredAgentIds = body.starred
|
||||
? [...new Set([...members.starredAgentIds, id])]
|
||||
: members.starredAgentIds.filter((i) => i !== id);
|
||||
return Response.json(members);
|
||||
}
|
||||
if (method === "POST" && path.endsWith("/comments")) {
|
||||
if (failSend) {
|
||||
failSend = false;
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
"Message could not be sent. Retry with your preserved draft.",
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const row = {
|
||||
...comment(crypto.randomUUID(), body.body),
|
||||
issueId: task.id,
|
||||
clientRequestId: body.clientRequestId,
|
||||
createdAt: new Date(
|
||||
Math.max(Date.now(), Date.parse("2026-09-10T16:00:00Z")),
|
||||
),
|
||||
...(body.body.trim() === "/new"
|
||||
? { conversationSessionGeneration: 1 }
|
||||
: {}),
|
||||
};
|
||||
messages.set(task.id, [...(messages.get(task.id) ?? []), row]);
|
||||
return Response.json(row);
|
||||
}
|
||||
if (method === "POST" && path.endsWith("/read")) return Response.json({});
|
||||
if (method === "PATCH" && /\/issues\/[^/]+$/.test(path)) {
|
||||
Object.assign(task, body);
|
||||
return Response.json(task);
|
||||
}
|
||||
if (method === "POST" && path.endsWith("/cancel")) {
|
||||
active = false;
|
||||
return Response.json({ ...run, status: "cancelled" });
|
||||
}
|
||||
if (method !== "GET")
|
||||
return Response.json(
|
||||
{
|
||||
error: "This operation is not configured in the Storybook fixture.",
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
if (path === "/api/cli-auth/me")
|
||||
return Response.json({
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [issue.companyId],
|
||||
memberships: [],
|
||||
});
|
||||
if (path === "/api/instance/settings/experimental")
|
||||
return Response.json({
|
||||
enableAgentChat: scenario !== "disabled",
|
||||
enableStreamlinedUi: true,
|
||||
enableClassicTaskInterface: false,
|
||||
enableExperimentalFileViewer: true,
|
||||
});
|
||||
if (path === "/api/instance/settings")
|
||||
return Response.json({ experimental: {} });
|
||||
if (path === "/api/instance/settings/general") return Response.json({});
|
||||
if (path.endsWith("/comments"))
|
||||
return Response.json([...(messages.get(task.id) ?? [])].reverse());
|
||||
if (path.endsWith("/queued-comments"))
|
||||
return Response.json({
|
||||
issueId: task.id,
|
||||
queueId: null,
|
||||
entries: [],
|
||||
revision: "empty",
|
||||
});
|
||||
if (path.endsWith("/tree-control/state"))
|
||||
return Response.json({ activePauseHold: null, activeHolds: [] });
|
||||
if (path.endsWith("/runs"))
|
||||
return Response.json(
|
||||
task.id === issue.id && scenario !== "empty"
|
||||
? [
|
||||
{
|
||||
...run,
|
||||
runId,
|
||||
usageJson: null,
|
||||
resultJson: null,
|
||||
logBytes: 2000,
|
||||
status: active ? "running" : "succeeded",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
if (path === `/api/heartbeat-runs/${runId}/log`) {
|
||||
const content = runLogContent();
|
||||
return Response.json({
|
||||
runId,
|
||||
store: "fixture",
|
||||
logRef: "fixture",
|
||||
content: Number(url.searchParams.get("offset") ?? 0) ? "" : content,
|
||||
nextOffset: content.length,
|
||||
});
|
||||
}
|
||||
if (path.includes("active-run"))
|
||||
return Response.json(
|
||||
active ? { ...run, id: runId, status: "running" } : null,
|
||||
);
|
||||
if (path.endsWith("/live-runs"))
|
||||
return Response.json(
|
||||
active
|
||||
? [{ ...run, id: runId, issueId: issue.id, status: "running" }]
|
||||
: [],
|
||||
);
|
||||
if (path.endsWith("/activity") && scenario.startsWith("project-") && scenario !== "project-failed" && scenario !== "project-reused") return Response.json([{
|
||||
id: "created-project-event", companyId: issue.companyId, actorType: "agent", actorId: agent.id,
|
||||
agentId: agent.id, runId, entityType: "project", entityId: "launch-project", action: "project.created",
|
||||
createdAt: "2026-09-10T15:42:30Z", details: {
|
||||
name: scenario === "project-multi-repo" ? "First agent handoff across the application, documentation, and onboarding service" : "First agent handoff",
|
||||
description: "Help new teams get their first useful result.", sourceIssueId: issue.id,
|
||||
repositories: scenario === "project-no-repo" ? [] : [
|
||||
{ id: "1", name: "paperclipai/paperclip", url: "https://github.com/paperclipai/paperclip" },
|
||||
...(scenario === "project-multi-repo" ? [{ id: "2", name: "paperclipai/onboarding", url: "https://github.com/paperclipai/onboarding" }] : []),
|
||||
],
|
||||
},
|
||||
}]);
|
||||
if (path.endsWith("/documents/plan"))
|
||||
return task.id === issue.id && scenario !== "empty"
|
||||
? Response.json(plan)
|
||||
: Response.json({ error: "No plan" }, { status: 404 });
|
||||
if (path.endsWith("/documents/notes")) return Response.json(notes);
|
||||
if (path.endsWith("/documents"))
|
||||
return Response.json(
|
||||
task.id === issue.id && scenario !== "empty" ? [plan, notes] : [],
|
||||
);
|
||||
if (/\/issues\/[^/]+$/.test(path))
|
||||
return Response.json(
|
||||
taskRef === child.id || taskRef === child.identifier ? child : task,
|
||||
);
|
||||
if (
|
||||
/\/companies\/[^/]+\/issues$/.test(path) &&
|
||||
(url.searchParams.has("parentId") || url.searchParams.has("descendantOf"))
|
||||
)
|
||||
return Response.json(scenario === "empty" ? [] : [child]);
|
||||
if (/\/companies\/[^/]+\/agents$/.test(path))
|
||||
return Response.json(fixtureAgents);
|
||||
if (/\/agents\/[^/]+$/.test(path))
|
||||
return Response.json(
|
||||
fixtureAgents.find(
|
||||
(a) => path.endsWith(a.id) || path.endsWith(agentRouteRef(a)),
|
||||
) ?? agent,
|
||||
);
|
||||
if (/^\/api\/adapters\/[^/]+\/config-schema$/.test(path))
|
||||
return Response.json({ error: "No schema override" }, { status: 404 });
|
||||
if (
|
||||
path === "/api/companies" ||
|
||||
path === "/api/auth/get-session" ||
|
||||
path === "/api/adapters" ||
|
||||
path === "/api/health" ||
|
||||
/\/companies\/[^/]+\/(projects|dashboard|sidebar-badges|user-directory|issues|approvals)$/.test(
|
||||
path,
|
||||
) ||
|
||||
/^\/api\/companies\/[^/]+\/(adapters\/|environments)/.test(path)
|
||||
)
|
||||
return originalFetch(input, init);
|
||||
return Response.json([]);
|
||||
};
|
||||
queryClient.clear();
|
||||
setReady(true);
|
||||
return () => {
|
||||
window.fetch = originalFetch;
|
||||
queryClient.clear();
|
||||
};
|
||||
}, [scenario, taskComparison, queryClient]);
|
||||
useEffect(() => {
|
||||
if (ready && location.pathname.endsWith("/storybook"))
|
||||
navigate(
|
||||
`/PAP/${taskComparison ? `issues/${issue.id}` : "chats/agent-codex"}`,
|
||||
{ replace: true },
|
||||
);
|
||||
}, [ready, navigate, location.pathname, taskComparison]);
|
||||
if (!ready) return null;
|
||||
return (
|
||||
<PluginLauncherProvider>
|
||||
<Routes>
|
||||
<Route path="/:companyPrefix" element={<Layout />}>
|
||||
<Route path="chats/:agentRef" element={<AgentChat />} />
|
||||
<Route path="issues/:issueId" element={<IssueDetail />} />
|
||||
<Route path="agents" element={<Agents />} />
|
||||
{AGENT_FILTER_TABS.map((tab) => (
|
||||
<Route key={tab} path={`agents/${tab}`} element={<Agents />} />
|
||||
))}
|
||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</PluginLauncherProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
|
||||
export const chatAgents = [
|
||||
...storybookAgents,
|
||||
{
|
||||
...storybookAgents[0],
|
||||
id: "chat-design",
|
||||
urlKey: "design-lead",
|
||||
name: "Design Lead",
|
||||
icon: "palette",
|
||||
},
|
||||
{
|
||||
...storybookAgents[0],
|
||||
id: "chat-research",
|
||||
urlKey: "researcher",
|
||||
name: "Researcher",
|
||||
icon: "search",
|
||||
},
|
||||
{
|
||||
...storybookAgents[0],
|
||||
id: "chat-ops",
|
||||
urlKey: "operations",
|
||||
name: "Operations",
|
||||
icon: "settings",
|
||||
},
|
||||
];
|
||||
export const chatIdentifier = (id: string) =>
|
||||
id === "agent-codex"
|
||||
? "PAP-241"
|
||||
: `PAP-${249 + chatAgents.findIndex((agent) => agent.id === id)}`;
|
||||
export const chatHref = (id: string) =>
|
||||
`/issues/${chatIdentifier(id)}?chatAgent=${encodeURIComponent(id)}`;
|
||||
|
||||
import { AgentChatSidebar as ProductionAgentChatSidebar } from "@/components/AgentChatSidebar";
|
||||
export function AgentChatSidebar(props: {
|
||||
activeId: string;
|
||||
starredIds: string[];
|
||||
recentIds: string[];
|
||||
onToggleStar: (id: string) => void;
|
||||
agents?: typeof chatAgents;
|
||||
}) {
|
||||
return (
|
||||
<ProductionAgentChatSidebar
|
||||
{...props}
|
||||
agents={props.agents ?? chatAgents}
|
||||
href={chatHref}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Agent chat production fixtures
|
||||
|
||||
These stories mount `AgentChat`, `TaskDetailSurface`, `Layout`, and their actual task transcript, composer, and side panel. They provide in-memory API responses; they contain no alternate chat controller or renderer. Sending appends a fixture comment, `/new` adds a shared session marker, and switching agents preserves each fixture history during the mounted story. Unsupported mutations fail explicitly.
|
||||
|
||||
The sidebar uses production resource memberships and company/user-scoped recent conversation visits. Starred agents sort alphabetically, followed by four recent unstarred agents. Stars appear on hover/focus. The gear and See all agents render the real agent configuration and roster pages. Roster Chat actions open the corresponding conversation.
|
||||
|
||||
Scenarios cover returning, first conversation, working, paused, failed send, long history, collapsed panel, light theme, ordinary task comparison, `/new`, and disabled experiment. Production API/runtime behavior is verified by server database/route tests; fixture replies do not represent live provider execution.
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentChatPrototype } from "../prototypes/agent-chat/AgentChatPrototype";
|
||||
|
||||
const meta = {
|
||||
title: "Design explorations/Agent chat",
|
||||
component: AgentChatPrototype,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Uses the actual production Layout (Sidebar, BreadcrumbBar, PropertiesPanel), TaskChatThread (TaskChatComposer, harness activity, thinking/tool disclosures, responses), and TaskSidePanel (plans, artifacts, subtasks). Agent shortcuts compose the existing sidebar rows and sections: starred agents first, then recent conversations, plus a See all link to the existing Agents page and a gear link to the existing agent configuration page. The task surfaces differ only in breadcrumb/title and initially open panel tabs. All data is fixture data; sends append locally and unsupported mutations fail explicitly.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
scenario: {
|
||||
control: "select",
|
||||
options: [
|
||||
"returning",
|
||||
"empty",
|
||||
"working",
|
||||
"paused",
|
||||
"error",
|
||||
"long",
|
||||
"new-session",
|
||||
"disabled",
|
||||
"project-created",
|
||||
"project-reused",
|
||||
"project-multi-repo",
|
||||
"project-no-repo",
|
||||
"project-failed",
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (args) => <AgentChatPrototype key={JSON.stringify(args)} {...args} />,
|
||||
} satisfies Meta<typeof AgentChatPrototype>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Returning: Story = {
|
||||
name: "01 · Pick up the conversation",
|
||||
args: { scenario: "returning" },
|
||||
};
|
||||
export const FirstConversation: Story = {
|
||||
name: "02 · First conversation",
|
||||
args: { scenario: "empty" },
|
||||
};
|
||||
export const Working: Story = {
|
||||
name: "03 · Agent is replying",
|
||||
args: { scenario: "working" },
|
||||
};
|
||||
export const Paused: Story = {
|
||||
name: "04 · Agent paused",
|
||||
args: { scenario: "paused" },
|
||||
};
|
||||
export const FailedSend: Story = {
|
||||
name: "05 · Failed send preserves draft",
|
||||
args: { scenario: "error" },
|
||||
};
|
||||
export const LongConversation: Story = {
|
||||
name: "06 · Long conversation",
|
||||
args: { scenario: "long" },
|
||||
};
|
||||
export const ConversationOnly: Story = {
|
||||
name: "07 · Context collapsed",
|
||||
args: { contextInitiallyOpen: false },
|
||||
};
|
||||
export const Light: Story = {
|
||||
name: "08 · Light",
|
||||
globals: { theme: "light" },
|
||||
args: { scenario: "returning" },
|
||||
};
|
||||
|
||||
export const TaskComparison: Story = {
|
||||
name: "09 · Same components with task chrome",
|
||||
args: { taskComparison: true },
|
||||
};
|
||||
|
||||
export const NewSession: Story = {
|
||||
name: "10 · New session preserves history",
|
||||
args: { scenario: "new-session" },
|
||||
};
|
||||
export const FeatureDisabled: Story = {
|
||||
name: "11 · Experiment disabled",
|
||||
args: { scenario: "disabled" },
|
||||
};
|
||||
|
||||
export const ProjectCreated: Story = { name: "12 · Plan handed off to a project task", args: { scenario: "project-created" } };
|
||||
export const MultipleRepositories: Story = { name: "13 · Project with multiple repositories", args: { scenario: "project-multi-repo" } };
|
||||
export const ProjectWithoutRepository: Story = { name: "14 · Non-code project", args: { scenario: "project-no-repo" } };
|
||||
export const ProjectCreationFailed: Story = { name: "15 · Failed project creation retains plan", args: { scenario: "project-failed" } };
|
||||
export const ProjectLight: Story = { name: "16 · Project created · light", globals: { theme: "light" }, args: { scenario: "project-created" } };
|
||||
|
||||
export const ExistingProject: Story = { name: "17 · Hand off to an existing project", args: { scenario: "project-reused" } };
|
||||
Loading…
Reference in New Issue