feat(slack): share task composer and isolate internal notes

This commit is contained in:
Scott Tong committed 2026-09-23 07:21:27 -07:00
1 parent fbcae3cd88
commit a03d8b394e
28 files changed
+1382 -47

No files matched your search

+21
View File
@@ -331,6 +331,27 @@ chat handoff. An agent's external file selection must match the attachment's
company, task, agent, and originating run. Editing or recreating a work-product
record cannot reassign that authority to a later run.
## Human-only task Internal notes
`issue_internal_notes` stores notes separately from `issue_comments`. Its
composite company/task foreign key prevents cross-company task attachment;
`(issue_id, author_user_id, client_request_id)` makes a human save retry-safe.
The company/task/timestamp/id index supports bounded, stable pagination without
rounding database timestamp precision through JavaScript dates. Task deletion
cascades notes. No historical ordinary comments are backfilled or reclassified.
The dedicated API requires a signed-in human browser session and current active
company membership; viewers can read but not write. Agent credentials, API keys
and local-implicit actors are denied. Notes are deliberately excluded from task
comments, search, references, agent context, activity bodies, wakes and external
chat publications. A transactional, content-free activity row records creation
once without an activity-stream/plugin publication. HTTP failure logging must
not serialize note bodies. There is no edit/delete API in this initial slice.
Notes are ordinary protected database data and are included in logical backups.
This is not a secrets vault or a host-level sandbox: a process with direct
database/filesystem access is outside the API/context isolation guarantee.
## Question-response delivery receipts
`issue_question_response_deliveries` is the retry-safe, content-free outbox for
+22 -10
View File
@@ -55,9 +55,11 @@ discovery only: no editing, delegation, new tasks, PR or merge.
Expect one Connect Slack card in Slack and the canonical Paperclip task. The
link hands off through the authenticated task to personal OAuth. After the user
approves, the existing durable intent delivery resumes the original request.
The answer keeps its original audience. A normal private Paperclip turn stays
private; Reply via Slack opts in to that turn's Slack reply. Not now in Slack
opens the board card for decline.
The answer keeps its original audience. On legacy v1 tasks, the normal Paperclip
composer stays internal and Reply via Slack opts in to that turn's Slack reply.
On threaded v2 tasks, the normal composer shares request and answer by default;
use the separate human-only Internal notes editor for non-agent notes. Not now
in Slack opens the board card for decline.
`ensure_capability` supports `slack.read_channel` and `slack.read_thread` only.
Its public states are CONNECTED, AUTH_REQUIRED and UNAVAILABLE. Search is
@@ -145,10 +147,14 @@ after activation get one task/thread, a deduplicated eyes receipt and editable
working status. Both native and legacy ACPX runs can supply safe coarse progress;
tool updates are coalesced, and short runs may only show working then the answer.
For new v2 bindings, **Reply via Slack** shares request and answer. The normal
Paperclip composer is still internal, disclosed in the banner. Default sharing,
human-only notes, explicit child-task linkage and historical screenshot research
remain unfinished. This is not full M2b acceptance. Focused verification includes
For new v2 bindings, the normal Paperclip composer now shares request and answer,
with an explicit destination disclosure. Shared requests are text-only and use
the existing durable outbox; the CEO starts only after Slack confirms delivery.
Separate **Internal notes** are human-only, stored outside task comments and
agent context. Earlier comments/drafts are not retroactively sent. The general
Tasks list now includes Idle conversations without changing dashboard attention
or execution behavior. Explicit child-task linkage and historical screenshot
research remain unfinished. This is not full M2b acceptance. Earlier focused verification includes
291 tests in 11 suites and 34 selected integration checks, plus server/UI/adapter
typechecks and the UI build. The bot upgrade has now completed: live status shows
active v2, reactions enabled and cutoff `1790145683.516000`. Real threaded Slack
@@ -159,9 +165,15 @@ receipt, one threaded working message, and an in-place final answer. Eyes are
removed on completion. Subsequent live checks verified same-thread continuity on
TES-4, a separate TES-5 root, labelled Paperclip request plus answer through Reply
via Slack, and “CEO is using tools…” followed by a final answer in the same thread.
The normal composer remains internal in this checkpoint. Next: Tasks-list
visibility for Idle conversations, shared-default v2 composer and isolated
human-only notes. No extra OAuth consent is needed for these UX fixes.
At the subsequent September 23 checkpoint, migration 0285 was applied after a
private backup. A live normal-composer request and its answer both reached the
TES-4 thread, each in one publication attempt; the task returned to Idle.
TES-4/TES-5 are visible in Tasks. A saved Internal-note canary did not enter
comments, Slack publications, wakes or activity, including after that shared run.
186 focused tests, server/UI typechecks, token gates, UI and Storybook builds
passed. Full visual-baseline acceptance remains unrun. Notes are in database
backups; their API/context isolation is not a sandbox against host/database
administrators. No extra OAuth consent was needed for these UX fixes.
Implementation, coding-worker approval, artifacts, GitHub and merge are later
milestones. Discovery guidance is not a security boundary for every CEO tool.
@@ -1,7 +1,7 @@
# Slack CEO M2b: threaded, repository-backed research
Status: narrow threaded-DM preview deployed and activated; live roots, follow-up,
explicit shared reply and coarse tool progress verified. Full
shared normal composer, isolated human-only notes and coarse tool progress verified. Full
M2b remains incomplete. Continue `codex/slack-ceo-poc1`,
not the independent `codex/connection-capability-092226` checkout. Starting
commit: `91339784ce9a740c149b34f013a1e29e7e7febdc`.
@@ -32,12 +32,17 @@ runner now emits a payload-free tool activity signal, coalesced at 20 seconds.
Short turns can go straight from working to the final answer. Never promise
individual grep/command names or expose raw arguments, reasoning or paths.
For v2, explicit **Reply via Slack** shares the request before waking the CEO,
then shares the answer. The banner explicitly calls this a preview: normal
Paperclip composer messages still remain internal. Shared-default composer plus
isolated human-only notes, explicit child-task announcement/binding, historical
scan storage and screenshot inspection remain future M2b work. Their absence
does not prevent the narrow new-DM test, but this is not full M2b acceptance.
For v2, the normal Paperclip composer now uses the existing shared-request outbox:
the labelled request reaches the exact Slack thread before the CEO wakes, and
its answer returns there. The composer discloses sharing and is text-only; task
file attachments are not yet supported. Its new, audience-specific draft key
does not restore earlier internal drafts. The redundant v2 **Reply via Slack**
box is removed; legacy TES-2 retains its original explicit-reply behavior.
Separate **Internal notes** are available to signed-in company members, never
through agent/API-key access or normal task context/search/publication. Existing
ordinary comments are not migrated into this store and remain agent-readable;
they are not retroactively posted to Slack. Explicit child-task binding,
historical scan storage and screenshot inspection remain future M2b work.
Verification: 291 tests across 11 focused suites; 34 selected chat integration
checks; server/UI/adapter-utils TypeScript checks; token gates and UI build pass.
@@ -88,12 +93,53 @@ retry and created TES-5 after the admission correction.
13 channel posts and 12 replies across three threads; this is reported coverage,
not an independent audit of those upstream reads. Attachments were not examined.
Remaining immediate UX fixes: keep Idle Slack conversations discoverable in the
Tasks list without making them active work; route the normal composer through the
existing shared-request outbox for v2 tasks; add separately stored human-only
Internal notes. Never retroactively publish existing internal comments. Historical
channel reading, screenshots and explicit child-task thread linkage remain later
work; the narrow preview does not claim those capabilities.
These checks exposed three immediate UX fixes, completed in the following checkpoint.
### Shared composer, Internal notes and Idle visibility (September 23, 00:28 PDT)
Pushed the prior threaded work as `fbcae3cd88da9decbea089e2c37eccc57f4145b1` to
`origin/codex/slack-ceo-poc1`, without a PR, before starting these fixes. The new
UX changes are local and running on the isolated pilot. A private logical backup
preceded generated migration `0285_abandoned_mentallo.sql` and server restart.
- The general Tasks list opts into Idle Slack conversations. Existing dashboard,
review-attention and execution counts retain their prior default exclusions.
Live Tasks inspection shows TES-2, TES-4 and TES-5 as Idle.
- Normal v2 composer submission uses the durable board-reply path, including
request IDs, confirmed-send-before-wake and authoritative audience checks.
Ambiguous responses preserve the request identity for retry; they never fall
back to an ordinary internal comment. File attachments/reassignment are rejected.
- `issue_internal_notes` is a separate company/task-scoped table with idempotent
human creation and paginated reads. Only an authenticated browser session plus
current active membership can read it; viewers cannot write. Agents, board API
keys and local-implicit actors cannot use the endpoint. Notes create no comment,
task mutation, wake, activity-body copy, SSE payload or chat publication. One
transactional content-free audit row records creation without a live event. HTTP
logs/errors use content-free projections. This is an API/context boundary, not
isolation from a host administrator or process with direct database access;
database backups contain notes. Do not use this feature as a secrets vault.
Live normal-composer test on TES-4 at 00:26 PDT: run
`4824bbc0-3c38-4bc7-83f0-48bbf2d518fd` succeeded and the task returned to Idle.
Request publication `6a449038-d7a7-4e0c-8041-27845b264b0c` and final publication
`211b11b1-5388-482e-9699-8d3f071082bd` each succeeded in one attempt. The real
Slack UI showed the labelled request and “Shared composer test passed.” under
the original TES-4 root. A harmless Internal-note canary saved beforehand remained
absent from comments, publications, wakes and activity after that shared run.
Saving it alone did not start any run.
Verification: 186 tests across eight focused suites passed, including real
temporary-database note isolation, tenant boundaries, viewer/agent denial,
idempotence, timestamp-precise pagination, ordinary composer retries and lifecycle
regressions. Server/UI TypeScript, migration checks, token gates, UI build and
Storybook build passed. Live UI inspection covered the notes dialog, shared
composer and Tasks list. Full visual-baseline acceptance was not run; the broader
baseline failures below remain. This is not a full-release or full-M2b claim.
Next: durable explicit new-task announcement/binding, then resumable historical
reads and actual screenshot inspection, followed by pinned read-only repository
assessment. TES-3 remains unlinked; do not infer authority from its prose source
link. No implementation, delegation, PR or merge workflow is enabled here.
## Current evidence and immediate repair
@@ -0,0 +1,14 @@
CREATE TABLE "issue_internal_notes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"issue_id" uuid NOT NULL,
"author_user_id" text NOT NULL,
"client_request_id" uuid NOT NULL,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "issue_internal_notes_request_uq" UNIQUE("issue_id","author_user_id","client_request_id")
);
--> statement-breakpoint
ALTER TABLE "issue_internal_notes" ADD CONSTRAINT "issue_internal_notes_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "issue_internal_notes" ADD CONSTRAINT "issue_internal_notes_issue_company_fk" FOREIGN KEY ("company_id","issue_id") REFERENCES "public"."issues"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "issue_internal_notes_issue_idx" ON "issue_internal_notes" USING btree ("company_id","issue_id","created_at","id");
@@ -1,6 +1,6 @@
{
"id": "861a2815-1418-4c71-a77b-d0e2e9566413",
"prevId": "4770ad6d-2cdc-45c4-af89-c300fd134d91",
"id": "e1655800-f133-4bf3-b810-a100641abcac",
"prevId": "8a038eb1-fe99-4de5-9608-3f4c7c2b53e5",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -5675,6 +5675,25 @@
"notNull": true,
"default": "''"
},
"binding_mode": {
"name": "binding_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'legacy'"
},
"origin_principal_id": {
"name": "origin_principal_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"origin_user_id": {
"name": "origin_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_generation": {
"name": "session_generation",
"type": "integer",
@@ -5701,6 +5720,12 @@
"notNull": true,
"default": false
},
"communication_guidance": {
"name": "communication_guidance",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "text",
@@ -5825,6 +5850,21 @@
"onDelete": "restrict",
"onUpdate": "no action"
},
"chat_conversations_origin_principal_fk": {
"name": "chat_conversations_origin_principal_fk",
"tableFrom": "chat_conversations",
"tableTo": "chat_external_principals",
"columnsFrom": [
"company_id",
"origin_principal_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"chat_conversations_company_issue_fk": {
"name": "chat_conversations_company_issue_fk",
"tableFrom": "chat_conversations",
@@ -5887,6 +5927,14 @@
"chat_conversations_state_check": {
"name": "chat_conversations_state_check",
"value": "\"chat_conversations\".\"state\" in ('active', 'waiting', 'completed', 'unavailable', 'endpoint_removed')"
},
"chat_conversations_binding_mode_check": {
"name": "chat_conversations_binding_mode_check",
"value": "\"chat_conversations\".\"binding_mode\" in ('legacy', 'slack_dm_thread_v2')"
},
"chat_conversations_slack_thread_binding_check": {
"name": "chat_conversations_slack_thread_binding_check",
"value": "\"chat_conversations\".\"binding_mode\" = 'legacy' or (\n \"chat_conversations\".\"is_direct_message\" and \"chat_conversations\".\"session_generation\" = 1\n and \"chat_conversations\".\"origin_principal_id\" is not null and \"chat_conversations\".\"origin_user_id\" is not null\n and \"chat_conversations\".\"external_conversation_id\" ~ '^D[A-Z0-9]+$'\n and \"chat_conversations\".\"external_thread_id\" ~ '^slack:D[A-Z0-9]+:[0-9]{1,12}[.][0-9]{1,6}$'\n and split_part(\"chat_conversations\".\"external_thread_id\", ':', 2) = \"chat_conversations\".\"external_conversation_id\")"
}
},
"isRLSEnabled": false
@@ -6611,6 +6659,13 @@
"primaryKey": false,
"notNull": false
},
"communication_instructions": {
"name": "communication_instructions",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"allow_direct_messages": {
"name": "allow_direct_messages",
"type": "boolean",
@@ -8096,6 +8151,519 @@
},
"isRLSEnabled": false
},
"public.chat_github_configurations": {
"name": "chat_github_configurations",
"schema": "",
"columns": {
"endpoint_id": {
"name": "endpoint_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"revision": {
"name": "revision",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"configuration": {
"name": "configuration",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"updated_by_user_id": {
"name": "updated_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
"name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
"tableFrom": "chat_github_configurations",
"tableTo": "chat_endpoints",
"columnsFrom": [
"company_id",
"endpoint_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"chat_github_config_revision_check": {
"name": "chat_github_config_revision_check",
"value": "\"chat_github_configurations\".\"revision\" > 0"
}
},
"isRLSEnabled": false
},
"public.chat_github_registrations": {
"name": "chat_github_registrations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"endpoint_id": {
"name": "endpoint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"state_hash": {
"name": "state_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"trusted_origin": {
"name": "trusted_origin",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"chat_github_registration_state_uq": {
"name": "chat_github_registration_state_uq",
"columns": [
{
"expression": "state_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_github_registration_endpoint_idx": {
"name": "chat_github_registration_endpoint_idx",
"columns": [
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "expires_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
"name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
"tableFrom": "chat_github_registrations",
"tableTo": "chat_endpoints",
"columnsFrom": [
"company_id",
"endpoint_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"chat_github_registration_status_check": {
"name": "chat_github_registration_status_check",
"value": "\"chat_github_registrations\".\"status\" in ('pending', 'exchanging', 'completed', 'failed')"
}
},
"isRLSEnabled": false
},
"public.chat_github_reviews": {
"name": "chat_github_reviews",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"endpoint_id": {
"name": "endpoint_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"issue_id": {
"name": "issue_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"repository_id": {
"name": "repository_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repository": {
"name": "repository",
"type": "text",
"primaryKey": false,
"notNull": true
},
"pull_number": {
"name": "pull_number",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"head_sha": {
"name": "head_sha",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delivery_id": {
"name": "delivery_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"configuration_revision": {
"name": "configuration_revision",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"policy_snapshot": {
"name": "policy_snapshot",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"event": {
"name": "event",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"assessment": {
"name": "assessment",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"conclusion": {
"name": "conclusion",
"type": "text",
"primaryKey": false,
"notNull": false
},
"check_id": {
"name": "check_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"check_url": {
"name": "check_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"summary_id": {
"name": "summary_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"summary_url": {
"name": "summary_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"publication_receipts": {
"name": "publication_receipts",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"chat_github_reviews_delivery_uq": {
"name": "chat_github_reviews_delivery_uq",
"columns": [
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "delivery_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_github_reviews_pull_idx": {
"name": "chat_github_reviews_pull_idx",
"columns": [
{
"expression": "endpoint_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "repository_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "pull_number",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"chat_github_reviews_task_idx": {
"name": "chat_github_reviews_task_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "issue_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"chat_github_reviews_run_id_heartbeat_runs_id_fk": {
"name": "chat_github_reviews_run_id_heartbeat_runs_id_fk",
"tableFrom": "chat_github_reviews",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"run_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
"name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
"tableFrom": "chat_github_reviews",
"tableTo": "chat_endpoints",
"columnsFrom": [
"company_id",
"endpoint_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": {
"name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk",
"tableFrom": "chat_github_reviews",
"tableTo": "issues",
"columnsFrom": [
"company_id",
"issue_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "restrict",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"chat_github_reviews_company_id_uq": {
"name": "chat_github_reviews_company_id_uq",
"nullsNotDistinct": false,
"columns": [
"company_id",
"id"
]
}
},
"policies": {},
"checkConstraints": {
"chat_github_review_pull_number_check": {
"name": "chat_github_review_pull_number_check",
"value": "\"chat_github_reviews\".\"pull_number\" > 0"
},
"chat_github_review_state_check": {
"name": "chat_github_review_state_check",
"value": "\"chat_github_reviews\".\"state\" in ('queued', 'running', 'completed', 'incomplete', 'error', 'superseded', 'manual_required')"
}
},
"isRLSEnabled": false
},
"public.chat_teams_file_transfers": {
"name": "chat_teams_file_transfers",
"schema": "",
@@ -25752,6 +26320,136 @@
},
"isRLSEnabled": false
},
"public.issue_internal_notes": {
"name": "issue_internal_notes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"issue_id": {
"name": "issue_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"author_user_id": {
"name": "author_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_request_id": {
"name": "client_request_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"issue_internal_notes_issue_idx": {
"name": "issue_internal_notes_issue_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "issue_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"issue_internal_notes_company_id_companies_id_fk": {
"name": "issue_internal_notes_company_id_companies_id_fk",
"tableFrom": "issue_internal_notes",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"issue_internal_notes_issue_company_fk": {
"name": "issue_internal_notes_issue_company_fk",
"tableFrom": "issue_internal_notes",
"tableTo": "issues",
"columnsFrom": [
"company_id",
"issue_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"issue_internal_notes_request_uq": {
"name": "issue_internal_notes_request_uq",
"nullsNotDistinct": false,
"columns": [
"issue_id",
"author_user_id",
"client_request_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.issue_labels": {
"name": "issue_labels",
"schema": "",
@@ -37416,6 +38114,26 @@
"notNull": true,
"default": true
},
"setup_pending": {
"name": "setup_pending",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"last_webhook_delivery": {
"name": "last_webhook_delivery",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"cron_expression": {
"name": "cron_expression",
"type": "text",
@@ -37677,6 +38395,100 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.routine_webhook_test_receipts": {
"name": "routine_webhook_test_receipts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"trigger_id": {
"name": "trigger_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"delivery_key_hash": {
"name": "delivery_key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"received_at": {
"name": "received_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"routine_webhook_test_receipts_delivery_uq": {
"name": "routine_webhook_test_receipts_delivery_uq",
"columns": [
{
"expression": "trigger_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "delivery_key_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"routine_webhook_test_receipts_company_id_companies_id_fk": {
"name": "routine_webhook_test_receipts_company_id_companies_id_fk",
"tableFrom": "routine_webhook_test_receipts",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk": {
"name": "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk",
"tableFrom": "routine_webhook_test_receipts",
"tableTo": "routine_triggers",
"columnsFrom": [
"trigger_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.routines": {
"name": "routines",
"schema": "",
+7
View File
@@ -1982,6 +1982,13 @@
"when": 1790143464911,
"tag": "0284_amusing_bloodaxe",
"breakpoints": true
},
{
"idx": 285,
"version": "7",
"when": 1790147738042,
"tag": "0285_abandoned_mentallo",
"breakpoints": true
}
]
}
+1
View File
@@ -41,6 +41,7 @@ export { projectGoals } from "./project_goals.js";
export { goals } from "./goals.js";
export { folders } from "./folders.js";
export { issues } from "./issues.js";
export { issueInternalNotes } from "./issue_internal_notes.js";
export { issueWatchdogs } from "./issue_watchdogs.js";
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
export { issueRecoveryActions } from "./issue_recovery_actions.js";
@@ -0,0 +1,19 @@
import { foreignKey, index, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
// Separate from issue_comments: never fed into agent context, search,
// references, activity bodies, wakes or external chat publications.
export const issueInternalNotes = pgTable("issue_internal_notes", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
issueId: uuid("issue_id").notNull(),
authorUserId: text("author_user_id").notNull(),
clientRequestId: uuid("client_request_id").notNull(),
body: text("body").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, table => ({
issueCompanyFk: foreignKey({ columns: [table.companyId, table.issueId], foreignColumns: [issues.companyId, issues.id], name: "issue_internal_notes_issue_company_fk" }).onDelete("cascade"),
requestUq: unique("issue_internal_notes_request_uq").on(table.issueId, table.authorUserId, table.clientRequestId),
issueIdx: index("issue_internal_notes_issue_idx").on(table.companyId, table.issueId, table.createdAt, table.id),
}));
@@ -13,6 +13,8 @@ import { createHttpLogger } from "../middleware/logger.js";
describe("HTTP logger redaction", () => {
it.each([
{ method: "POST", path: "/api/issues/private-url-canary/internal-notes" },
{ method: "GET", path: "/api/issues/private-url-canary/internal-notes?before=private-url-canary" },
{ method: "POST", path: "/api/routine-triggers/public/private-url-canary/fire" },
{ method: "PUT", path: "/api/routine-triggers/public/private-url-canary/fire" },
@@ -98,7 +100,7 @@ describe("HTTP logger redaction", () => {
const log = JSON.parse(output.trim());
expect(log.req).toMatchObject({
method,
url: path.includes("routine-triggers") ? "/api/routine-triggers/public/:publicId/fire" : "/api/chat-webhooks/:publicId/:provider",
url: path.includes("internal-notes") ? "/api/issues/:id/internal-notes" : path.includes("routine-triggers") ? "/api/routine-triggers/public/:publicId/fire" : "/api/chat-webhooks/:publicId/:provider",
});
expect(log.reqBody).toBe("[REDACTED]");
expect(log.err.message).toBe("Chat webhook request failed");
@@ -0,0 +1,86 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { activityLog, agentWakeupRequests, chatActions, chatPublications, companies, companyMemberships, createDb,
issueComments, issueInternalNotes, issues } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { issueInternalNoteRoutes } from "../routes/issue-internal-notes.js";
import { errorHandler } from "../middleware/error-handler.js";
import { issueService } from "../services/issues.js";
import { companySearchService } from "../services/company-search.js";
import { companySearchQuerySchema } from "@paperclipai/shared";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("human-only Internal notes", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, db: ReturnType<typeof createDb>;
beforeAll(async () => { database = await startEmbeddedPostgresTestDatabase("paperclip-private-notes-"); db = createDb(database.connectionString); }, 90000);
afterAll(async () => { await db?.$client.end({ timeout: 0 }); await database?.cleanup(); });
async function fixture() {
const companyId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Private notes", issuePrefix: companyId.slice(0, 8) });
await db.insert(issues).values({ id: issueId, companyId, title: "Thread", status: "in_review" });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
const actor = { type: "board", source: "session", sessionId: randomUUID(), userId, companyIds: [companyId] };
const appFor = (override = {}) => {
const app = express(); app.use(express.json());
app.use((req, _res, next) => { (req as any).actor = { ...actor, ...override }; next(); });
app.use("/api", issueInternalNoteRoutes(db)); app.use(errorHandler);
return app;
};
return { companyId, issueId, userId, actor, appFor, path: `/api/issues/${issueId}/internal-notes` };
}
it("stores one idempotent note separately without comments, search, task mutation, work or publication", async () => {
const f = await fixture(), app = f.appFor();
const input = { body: "human-only-canary-91427", clientRequestId: randomUUID() };
const before = await db.select().from(issues).where(eq(issues.id, f.issueId));
const [a, b] = await Promise.all([request(app).post(f.path).send(input), request(app).post(f.path).send(input)]);
expect(a.status).toBe(201); expect(b.body.id).toBe(a.body.id);
expect((await request(app).get(f.path)).body.notes).toHaveLength(1);
expect((await request(app).get(f.path)).headers["cache-control"]).toBe("no-store");
expect(await db.select().from(issues).where(eq(issues.id, f.issueId))).toEqual(before);
for (const table of [issueComments, chatPublications, chatActions, agentWakeupRequests]) {
expect(await db.select().from(table).where(eq(table.companyId, f.companyId))).toHaveLength(0);
}
const audit = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId));
expect(audit).toHaveLength(1);
expect(audit[0]).toMatchObject({ action: "issue_internal_note.created", entityType: "issue_internal_note",
entityId: a.body.id, actorType: "user", actorId: f.userId, details: null });
expect(JSON.stringify(audit)).not.toContain(input.body);
expect(await issueService(db).listComments(f.issueId)).toHaveLength(0);
expect((await companySearchService(db).search(f.companyId, companySearchQuerySchema.parse({ q: input.body }))).results).toHaveLength(0);
expect((await request(app).post(f.path).send({ ...input, body: "different" })).status).toBe(409);
});
it.each([
{ type: "agent", onBehalfOfUserId: "human" }, { type: "none" },
{ source: "board_api_key" }, { source: "local_implicit" }, { sessionId: undefined },
])("denies non-human-session access on both paths: %j", async override => {
const f = await fixture(), app = f.appFor(override);
expect((await request(app).get(f.path)).status).toBe(403);
expect((await request(app).post(f.path).send({ body: "secret", clientRequestId: randomUUID() })).status).toBe(403);
});
it("uses current company membership, not cached actor membership or admin claims", async () => {
const f = await fixture(), app = f.appFor({ isInstanceAdmin: true });
await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.companyId, f.companyId));
expect((await request(app).get(f.path)).status).toBe(404);
expect((await request(app).get(`/api/issues/${randomUUID()}/internal-notes`)).status).toBe(404);
expect((await request(f.appFor({ userId: randomUUID() })).get(f.path)).status).toBe(404);
});
it("lets current viewers read but not write", async () => {
const f = await fixture();
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.companyId, f.companyId));
expect((await request(f.appFor()).get(f.path)).status).toBe(200);
expect((await request(f.appFor()).post(f.path).send({ body: "no", clientRequestId: randomUUID() })).status).toBe(403);
});
it("paginates without leaking another task's cursor, and database rejects mixed-company notes", async () => {
const f = await fixture(), other = await fixture();
await db.insert(issueInternalNotes).values(Array.from({ length: 51 }, (_, i) => ({ companyId: f.companyId, issueId: f.issueId, authorUserId: f.userId, body: String(i), clientRequestId: randomUUID() })));
const first = await request(f.appFor()).get(f.path);
expect(first.body.notes).toHaveLength(50);
const last = await request(f.appFor()).get(f.path).query({ before: first.body.nextCursor });
expect(last.body.notes).toHaveLength(1); expect(last.body.nextCursor).toBeNull();
expect((await request(other.appFor()).get(other.path).query({ before: first.body.nextCursor })).status).toBe(404);
await expect(db.insert(issueInternalNotes).values({ companyId: other.companyId, issueId: f.issueId, authorUserId: f.userId, body: "no", clientRequestId: randomUUID() })).rejects.toThrow();
});
});
@@ -139,6 +139,10 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await db.select().from(issues).where(and(eq(issues.id, f.issueId), executionIssueCondition()))).toHaveLength(0);
expect((await issueService(db).getById(f.issueId))?.externalConversationState).toBe("waiting");
expect(await issueService(db).list(f.companyId)).toHaveLength(0);
expect(await issueService(db).list(f.companyId, { includeIdleSlackConversations: true }))
.toEqual([expect.objectContaining({ id: f.issueId, externalConversationState: "waiting" })]);
expect(await issueService(db).count(f.companyId, { includeIdleSlackConversations: true })).toBe(1);
expect(await issueService(db).count(f.companyId)).toBe(0);
expect((await dashboardService(db).summary(f.companyId)).tasks.open).toBe(0);
expect((await attentionService(db).list(f.companyId)).items.filter((item) => item.issueId === f.issueId)).toHaveLength(0);
const results = await companySearchService(db).search(f.companyId, companySearchQuerySchema.parse({ q: "you there" }));
@@ -157,7 +161,7 @@ const support = await getEmbeddedPostgresTestSupport();
});
app.use("/api", issueRoutes(db, {} as any));
const response = await request(app).get(`/api/companies/${f.companyId}/issues`)
.query({ view: "compact", q: "you there" });
.query({ view: "compact", includeIdleSlackConversations: "true" });
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body).toEqual([expect.objectContaining({ id: f.issueId, status: "in_review", externalConversationState: "waiting" })]);
});
+2
View File
@@ -63,6 +63,7 @@ import { environmentService } from "./services/environments.js";
import { environmentRuntimeService } from "./services/environment-runtime.js";
import { projectRoutes } from "./routes/projects.js";
import { issueRoutes } from "./routes/issues.js";
import { issueInternalNoteRoutes } from "./routes/issue-internal-notes.js";
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
import { caseRoutes } from "./routes/cases.js";
import { fileResourceRoutes } from "./routes/file-resources.js";
@@ -833,6 +834,7 @@ export async function createApp(
// Issue routes are intentionally mounted after the gateway is constructed because
// issue approval endpoints delegate to it. The intervening routers use distinct
// route prefixes, so this dependency does not change issue-route precedence.
api.use(issueInternalNoteRoutes(db));
api.use(issueRoutes(db, opts.storageService, {
chatRunRetries: chatChannels,
feedbackExportService: opts.feedbackExportService,
+5
View File
@@ -37,6 +37,10 @@ function normalizePath(url: string): string {
return pathname.length > 0 ? pathname : "/";
}
export function isInternalNoteHttpRequest(url: string | undefined): boolean {
return Boolean(url && /\/api\/issues\/[^/]+\/internal-notes(?:\/|$)/i.test(normalizePath(url)));
}
const SECRET_SENSITIVE_HTTP_PATHS = [
/^\/api\/chat-endpoints\/[^/]+\/setup(?:-secret)?(?:\/|$)/,
];
@@ -77,6 +81,7 @@ export function isSecretSensitiveHttpRequest(
url: string | undefined,
): boolean {
if (isPrivateWebhookHttpRequest(method, url)) return true;
if (isInternalNoteHttpRequest(url)) return true;
if (!method || !url) return false;
if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false;
const pathname = normalizePath(url);
+3 -1
View File
@@ -4,6 +4,7 @@ import { pinoHttp } from "pino-http";
import { HTTP_LOG_REDACT_PATHS } from "./http-log-redaction.js";
import {
isPrivateWebhookHttpRequest,
isInternalNoteHttpRequest,
isSecretSensitiveHttpRequest,
shouldSilenceHttpSuccessLog,
} from "./http-log-policy.js";
@@ -56,13 +57,14 @@ function isPrivateWebhook(req: {
originalUrl?: unknown;
url?: unknown;
}) {
return isPrivateWebhookHttpRequest(
return isInternalNoteHttpRequest(requestClassificationUrl(req)) || isPrivateWebhookHttpRequest(
req.method,
requestClassificationUrl(req),
);
}
function privateWebhookLogUrl(url: unknown) {
if (typeof url === "string" && isInternalNoteHttpRequest(url)) return "/api/issues/:id/internal-notes";
return typeof url === "string" && /\/routine-triggers\/public(?:\/|$)/i.test(url)
? "/api/routine-triggers/public/:publicId/fire"
: "/api/chat-webhooks/:publicId/:provider";
+70
View File
@@ -0,0 +1,70 @@
import { Router, type Request } from "express";
import { and, desc, eq, sql } from "drizzle-orm";
import { z } from "zod";
import { companyMemberships, issueInternalNotes, issues, type Db } from "@paperclipai/db";
import { conflict, forbidden, notFound } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { persistActivity } from "../services/activity-log.js";
const noteInput = z.object({ body: z.string().trim().min(1).max(8000), clientRequestId: z.string().uuid() }).strict();
/** No agent/API-key/local-implicit access, even when acting for a human. These
* notes intentionally have no general comments/search/activity integration. */
export function issueInternalNoteRoutes(db: Db) {
const router = Router();
async function authorize(req: Request, tx: Db, write = false) {
if (req.actor.type !== "board" || req.actor.source !== "session" || !req.actor.userId || !req.actor.sessionId) {
throw forbidden("Sign in as a person to access Internal notes");
}
const id = z.string().uuid().parse(req.params.id);
const [row] = await tx.select({ issue: issues, membership: companyMemberships }).from(issues)
.innerJoin(companyMemberships, and(eq(companyMemberships.companyId, issues.companyId),
eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, req.actor.userId),
eq(companyMemberships.status, "active")))
.where(eq(issues.id, id)).limit(1);
if (!row) throw notFound("Task not found");
if (write && row.membership.membershipRole === "viewer") throw forbidden("Viewer access is read-only");
return { issue: row.issue, userId: req.actor.userId };
}
router.get("/issues/:id/internal-notes", async (req, res) => {
const { issue } = await authorize(req, db);
const conditions = [eq(issueInternalNotes.companyId, issue.companyId), eq(issueInternalNotes.issueId, issue.id)];
if (req.query.before) {
const id = z.string().uuid().parse(req.query.before);
const [cursor] = await db.select().from(issueInternalNotes).where(and(...conditions, eq(issueInternalNotes.id, id)));
if (!cursor) throw notFound("Note not found");
// Keep PostgreSQL timestamp precision; round-tripping through Date loses
// microseconds and can skip notes sharing a transaction timestamp.
conditions.push(sql`(${issueInternalNotes.createdAt}, ${issueInternalNotes.id}) < (
select n.created_at, n.id from issue_internal_notes n
where n.id = ${cursor.id} and n.company_id = ${issue.companyId} and n.issue_id = ${issue.id})`);
}
const rows = await db.select().from(issueInternalNotes).where(and(...conditions))
.orderBy(desc(issueInternalNotes.createdAt), desc(issueInternalNotes.id)).limit(51);
res.set("Cache-Control", "no-store").json({ notes: rows.slice(0, 50), nextCursor: rows.length > 50 ? rows[49].id : null });
});
router.post("/issues/:id/internal-notes", validate(noteInput), async (req, res) => {
const note = await db.transaction(async tx => {
const { issue, userId } = await authorize(req, tx as unknown as Db, true);
const [created] = await tx.insert(issueInternalNotes).values({ companyId: issue.companyId, issueId: issue.id,
authorUserId: userId, body: req.body.body, clientRequestId: req.body.clientRequestId })
.onConflictDoNothing().returning();
const [saved] = created ? [created] : await tx.select().from(issueInternalNotes).where(and(
eq(issueInternalNotes.issueId, issue.id), eq(issueInternalNotes.authorUserId, userId),
eq(issueInternalNotes.clientRequestId, req.body.clientRequestId)));
if (!saved || saved.body !== req.body.body) throw conflict("This request ID belongs to a different note");
if (created) {
// Audit the mutation once without copying the body or publishing an
// activity event into the task stream, plugin bus or agent context.
await persistActivity(tx as unknown as Db, {
companyId: issue.companyId, actorType: "user", actorId: userId,
action: "issue_internal_note.created", entityType: "issue_internal_note", entityId: created.id,
});
}
// No task mutation, comment, audit-body copy, wake, SSE payload or outbox.
return saved;
});
res.set("Cache-Control", "no-store").status(201).json(note);
});
return router;
}
+1
View File
@@ -8102,6 +8102,7 @@ export function issueRoutes(
includeRoutineExecutions:
req.query.includeRoutineExecutions === "true" ||
req.query.includeRoutineExecutions === "1",
includeIdleSlackConversations: req.query.includeIdleSlackConversations === "true",
excludeRoutineExecutions:
req.query.excludeRoutineExecutions === "true" ||
req.query.excludeRoutineExecutions === "1",
+4 -2
View File
@@ -1767,6 +1767,8 @@ export interface IssueFilters {
originKindPrefix?: string;
originId?: string;
includeRoutineExecutions?: boolean;
/** Browse resting Slack conversations without treating them as active work. */
includeIdleSlackConversations?: boolean;
excludeRoutineExecutions?: boolean;
includePluginOperations?: boolean;
includeBlockedBy?: boolean;
@@ -7795,7 +7797,7 @@ export function issueService(db: Db) {
];
if (!filters?.q?.trim()) {
conditions.push(isNull(issues.conversationAgentId));
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
if (!filters?.includeIdleSlackConversations && !filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
conditions.push(nonIdleSlackIssueCondition());
}
}
@@ -8129,7 +8131,7 @@ export function issueService(db: Db) {
const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()];
if (!filters?.q?.trim()) {
conditions.push(isNull(issues.conversationAgentId));
if (!filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
if (!filters?.includeIdleSlackConversations && !filters?.touchedByUserId && !filters?.unreadForUserId && !filters?.inboxArchivedByUserId) {
conditions.push(nonIdleSlackIssueCondition());
}
}
+9
View File
@@ -0,0 +1,9 @@
import { api } from "./client";
export type InternalNote = { id: string; companyId: string; issueId: string; authorUserId: string; body: string; createdAt: string };
export const internalNotesApi = {
list: (issueId: string, before?: string) => api.get<{ notes: InternalNote[]; nextCursor: string | null }>(
`/issues/${issueId}/internal-notes${before ? `?before=${encodeURIComponent(before)}` : ""}`),
create: (issueId: string, body: string, clientRequestId: string) => api.post<InternalNote>(
`/issues/${issueId}/internal-notes`, { body, clientRequestId }),
};
+3
View File
@@ -98,6 +98,7 @@ export type IssueListFilters = {
descendantOf?: string;
createdFromIssueId?: string;
includeRoutineExecutions?: boolean;
includeIdleSlackConversations?: boolean;
includeBlockedBy?: boolean;
includeBlockedInboxAttention?: boolean;
includeLiveDescendantSummary?: boolean;
@@ -140,6 +141,8 @@ function issueListSearchParams(filters?: IssueListFilters) {
if (filters?.createdFromIssueId) params.set("createdFromIssueId", filters.createdFromIssueId);
if (filters?.includeRoutineExecutions)
params.set("includeRoutineExecutions", "true");
if (filters?.includeIdleSlackConversations)
params.set("includeIdleSlackConversations", "true");
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
if (filters?.includeBlockedInboxAttention)
params.set("includeBlockedInboxAttention", "true");
@@ -170,11 +170,13 @@ describe("ExternallyConnectedTaskBanner publication truth", () => {
expect(container.textContent).toContain("Connection changed; reload before upgrading");
});
it("labels the threaded preview's remaining composer limitation", async () => {
it("discloses the shared default without republishing old comments", async () => {
const binding = await mockChatEndpointsApi.getIssueBinding();
mockChatEndpointsApi.getIssueBinding.mockResolvedValue({ ...binding, bindingMode: "slack_dm_thread_v2" });
await renderBanner();
expect(container.textContent).toContain("normal Paperclip composer does not send messages to Slack yet");
expect(container.textContent).toContain("Messages sent from the composer and the CEO’s answers are shared");
expect(container.textContent).toContain("Earlier Paperclip messages are not automatically copied");
expect(container.textContent).not.toContain("Reply via Slack");
expect(container.textContent).not.toContain("Enable threaded DMs");
});
@@ -110,7 +110,7 @@ const filePhaseLabels: Record<ChatFileTransferPhase, string> = {
};
export function useIssueChatBinding(companyId: string, issueId: string) {
const { enabled } = useChatConnectorsEnabled();
const { enabled, loaded } = useChatConnectorsEnabled();
const queryEnabled = enabled && Boolean(companyId && issueId) && !issueId.startsWith("chat:");
const query = useQuery({
queryKey: ["issue-chat-binding", companyId, issueId],
@@ -119,7 +119,8 @@ export function useIssueChatBinding(companyId: string, issueId: string) {
});
return {
binding: queryEnabled ? (query.data ?? null) : null,
isLoading: queryEnabled && query.isLoading,
isLoading: !loaded || (queryEnabled && query.isLoading),
isError: queryEnabled && query.isError,
};
}
@@ -536,8 +537,8 @@ function ConnectedTaskComposer({
{binding.slackThreadUpgradeAvailable && <SlackThreadUpgradeNotice pending={upgradeThreads.isPending}
error={upgradeThreads.isError ? (upgradeThreads.error instanceof Error ? upgradeThreads.error.message : "Could not start the upgrade.") : null}
onUpgrade={() => upgradeThreads.mutate()} />}
{binding.bindingMode === "slack_dm_thread_v2" && <p className="text-xs text-muted-foreground">Thread preview: use Reply via Slack to share a request and the CEO’s answer in this thread. The normal Paperclip composer does not send messages to Slack yet.</p>}
{binding.slackReplyAvailable && <SlackReplyComposer sharedThread={binding.bindingMode === "slack_dm_thread_v2"} companyId={companyId} issueId={issueId} issueCacheRefs={issueCacheRefs}
{binding.bindingMode === "slack_dm_thread_v2" && <p className="text-xs text-muted-foreground">Messages sent from the composer and the CEO’s answers are shared in this Slack thread. Internal notes stay with people in Paperclip. Earlier Paperclip messages are not automatically copied.</p>}
{binding.slackReplyAvailable && binding.bindingMode !== "slack_dm_thread_v2" && <SlackReplyComposer companyId={companyId} issueId={issueId} issueCacheRefs={issueCacheRefs}
endpointId={binding.endpointId} conversationId={binding.conversationId} />}
{composing && (
<div className="space-y-2 border-t border-border pt-3">
@@ -842,7 +843,7 @@ function ConnectedTaskComposer({
)}
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
Ordinary board comments remain Paperclip-only.
{binding.bindingMode === "slack_dm_thread_v2" ? "This sends an update without asking the CEO to respond. Use the task composer for a conversation." : "Ordinary board comments remain Paperclip-only."}
</p>
<Button
size="sm"
@@ -0,0 +1,52 @@
// @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 { TaskInternalNotes } from "./TaskInternalNotes";
import { internalNotesApi } from "@/api/internalNotes";
vi.mock("@/api/internalNotes", () => ({ internalNotesApi: { list: vi.fn(), create: vi.fn() } }));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("isolated human-note editor", () => {
let root: Root, element: HTMLDivElement, client: QueryClient;
beforeEach(() => {
vi.clearAllMocks(); localStorage.clear();
vi.spyOn(localStorage, "setItem");
element = document.createElement("div"); document.body.append(element); root = createRoot(element);
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
vi.mocked(internalNotesApi.list).mockResolvedValue({ notes: [], nextCursor: null });
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); element.remove(); vi.restoreAllMocks(); });
const button = (text: string) => [...document.querySelectorAll("button")].find(b => b.textContent === text)!;
async function render(issueId = "issue") {
await act(async () => root.render(<QueryClientProvider client={client}><TaskInternalNotes key={issueId} issueId={issueId} companyId="company" userId="user" /></QueryClientProvider>));
}
async function enter(text: string) {
await act(async () => {
const input = document.querySelector("textarea")!;
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(input, text);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
it("uses a separate editor, saves with one retry ID, and never puts note text in localStorage", async () => {
vi.mocked(internalNotesApi.create).mockRejectedValueOnce(new Error("lost receipt"));
await render(); await act(async () => button("Internal notes").click());
expect(document.body.textContent).toContain("Not sent to Slack or included in agent context");
await enter("private-canary");
await act(async () => button("Save Internal note").click());
await vi.waitFor(() => expect(button("Retry same note")).toBeDefined());
const id = vi.mocked(internalNotesApi.create).mock.calls[0][2];
vi.mocked(internalNotesApi.create).mockResolvedValueOnce({ id: "note", companyId: "company", issueId: "issue", authorUserId: "user", body: "private-canary", createdAt: new Date().toISOString() });
await act(async () => button("Retry same note").click());
expect(internalNotesApi.create).toHaveBeenNthCalledWith(2, "issue", "private-canary", id);
await vi.waitFor(() => expect(document.body.textContent).toContain("The CEO was not notified"));
expect(localStorage.setItem).not.toHaveBeenCalled();
});
it("does not carry an unsaved note into another task", async () => {
await render(); await act(async () => button("Internal notes").click()); await enter("private-canary");
await render("other"); await act(async () => button("Internal notes").click());
expect(document.querySelector("textarea")?.value).toBe("");
expect(internalNotesApi.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
import { useState } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { internalNotesApi } from "@/api/internalNotes";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
/** Separate editor and query cache: private drafts never enter the task
* composer, its localStorage, transcript, copy/export or agent history. */
export function TaskInternalNotes({ issueId, companyId, userId }: { issueId: string; companyId: string; userId: string }) {
const [open, setOpen] = useState(false);
const [body, setBody] = useState("");
const [pending, setPending] = useState<{ body: string; id: string } | null>(null);
const client = useQueryClient();
const key = ["human-internal-notes", companyId, userId, issueId];
const notes = useInfiniteQuery({ queryKey: key, enabled: open, gcTime: 0,
queryFn: ({ pageParam }) => internalNotesApi.list(issueId, pageParam ?? undefined),
initialPageParam: null as string | null, getNextPageParam: page => page.nextCursor ?? undefined });
const save = useMutation({
mutationFn: async () => {
const request = pending ?? { body: body.trim(), id: crypto.randomUUID() };
setPending(request);
const saved = await internalNotesApi.create(issueId, request.body, request.id);
if (!saved?.id || saved.issueId !== issueId || saved.body !== request.body) throw new Error("Could not confirm the saved note. Retry this same note.");
return saved;
},
onSuccess: () => { setBody(""); setPending(null); void client.invalidateQueries({ queryKey: key }); },
});
return <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild><Button size="sm" variant="ghost">Internal notes</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Internal notes</DialogTitle><DialogDescription>
For signed-in people in this company only. Not sent to Slack or included in agent context. Text only; unsaved notes are lost if you leave this task.
</DialogDescription></DialogHeader>
<div className="max-h-64 overflow-y-auto space-y-3">
{notes.isPending && <p className="text-sm text-muted-foreground">Loading notes…</p>}
{notes.isError && <p role="alert" className="text-sm text-destructive">Could not load notes. <Button variant="link" onClick={() => void notes.refetch()}>Retry</Button></p>}
{notes.data?.pages.flatMap(page => page.notes).map(note => <div key={note.id} className="space-y-1">
<p className="text-xs text-muted-foreground">{note.authorUserId === userId ? "You" : "Team member"} · {new Date(note.createdAt).toLocaleString()}</p>
<p className="whitespace-pre-wrap break-words text-sm">{note.body}</p>
</div>)}
{notes.data?.pages[0]?.notes.length === 0 && <p className="text-sm text-muted-foreground">No Internal notes yet.</p>}
{notes.hasNextPage && <Button variant="ghost" disabled={notes.isFetchingNextPage} onClick={() => void notes.fetchNextPage()}>Older notes</Button>}
</div>
<label htmlFor="task-internal-note" className="text-sm">New Internal note</label>
<Textarea id="task-internal-note" value={body} maxLength={8000} disabled={save.isPending || Boolean(pending)} onChange={event => { setBody(event.target.value); save.reset(); }} />
{save.isError && <p role="alert" className="text-sm text-destructive">Could not confirm the save. Retry the same note; it will not create a duplicate.</p>}
{save.isSuccess && <p role="status" className="text-sm text-muted-foreground">Note saved for people only. The CEO was not notified.</p>}
<div className="flex items-center justify-between gap-3">
<Button variant="ghost" onClick={() => setOpen(false)}>Close</Button>
<Button disabled={!body.trim() || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Saving…" : pending ? "Retry same note" : "Save Internal note"}</Button>
</div>
</DialogContent>
</Dialog>;
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { chatEndpointsApi, type ExternalChannelBindingSummary } from "@/api/chatEndpoints";
import { ApiError } from "@/api/client";
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
import { submitSharedThreadMessage } from "./shared-thread-submit";
vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: { requestSlackReply: vi.fn() } }));
const binding = { provider: "slack", bindingMode: "slack_dm_thread_v2", slackReplyAvailable: true, endpointId: "endpoint", conversationId: "thread" } as ExternalChannelBindingSummary;
describe("normal composer shared-thread submission", () => {
it("uses the existing durable reply path and preserves the caller's retry identity", async () => {
const receipt = { requestId: "action", commentId: "comment", status: "queued" } as const;
vi.mocked(chatEndpointsApi.requestSlackReply).mockResolvedValue(receipt);
expect(await submitSharedThreadMessage(binding, "hello", "same-id")).toEqual(receipt);
expect(chatEndpointsApi.requestSlackReply).toHaveBeenLastCalledWith("endpoint", "thread", "hello", "same-id");
});
it.each([{}, { bindingMode: "legacy" }, { slackReplyAvailable: false }])("does not fall back to a private comment for an invalid binding", async changes => {
const invalid = Object.keys(changes).length ? { ...binding, ...changes } : { ...binding, provider: "discord" };
await expect(submitSharedThreadMessage(invalid as ExternalChannelBindingSummary, "hello", "id")).rejects.toThrow("unavailable");
});
it("refuses untransported attachments and absent retry identities", async () => {
await expect(submitSharedThreadMessage(binding, "hello", "id", ["file"])).rejects.toThrow("text-only");
await expect(submitSharedThreadMessage(binding, "hello")).rejects.toThrow("send identity");
});
it.each([new Error("Lost response"), { requestId: "incomplete" }])("keeps ambiguous receipts distinguishable for the composer retry state", async outcome => {
if (outcome instanceof Error) vi.mocked(chatEndpointsApi.requestSlackReply).mockRejectedValueOnce(outcome);
else vi.mocked(chatEndpointsApi.requestSlackReply).mockResolvedValueOnce(outcome as any);
await expect(submitSharedThreadMessage(binding, "hello", "same-id")).rejects.toBeInstanceOf(CommentSubmissionUnknownError);
});
it("keeps definite authorization rejection distinct from lost delivery", async () => {
const error = new ApiError("No access", 403, {});
vi.mocked(chatEndpointsApi.requestSlackReply).mockRejectedValueOnce(error);
await expect(submitSharedThreadMessage(binding, "hello", "id")).rejects.toBe(error);
});
});
@@ -0,0 +1,18 @@
import { ApiError } from "@/api/client";
import { chatEndpointsApi, type ExternalChannelBindingSummary } from "@/api/chatEndpoints";
import { CommentSubmissionUnknownError } from "@/lib/comment-submit-result";
export async function submitSharedThreadMessage(binding: ExternalChannelBindingSummary, body: string, clientRequestId?: string, attachmentIds?: string[]) {
if (binding.provider !== "slack" || binding.bindingMode !== "slack_dm_thread_v2" || !binding.slackReplyAvailable) throw new Error("The Slack thread is unavailable. Check its connection before sending.");
if (!clientRequestId) throw new Error("Reload this task to create a safe send identity.");
if (attachmentIds?.length || /!?\[[^\]]*\]\([^)]*\/api\/[^)]*\)/.test(body)) throw new Error("Shared Slack requests are text-only for now. Remove task-file attachments before sending.");
if (!body.trim() || body.length > 8000) throw new Error("Send between 1 and 8,000 characters.");
try {
const receipt = await chatEndpointsApi.requestSlackReply(binding.endpointId, binding.conversationId, body, clientRequestId);
if (!receipt?.requestId || !receipt.commentId || !["queued", "submitted", "failed"].includes(receipt.status)) throw new CommentSubmissionUnknownError();
return receipt;
} catch (error) {
if (error instanceof ApiError && error.status < 500) throw error;
throw new CommentSubmissionUnknownError();
}
}
+39 -10
View File
@@ -209,7 +209,9 @@ import {
hasVisibleMonitorSurface,
} from "../components/IssueMonitorBanner";
import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
import { ExternallyConnectedTaskBanner } from "../components/chat/ExternallyConnectedTaskBanner";
import { ExternallyConnectedTaskBanner, useIssueChatBinding } from "../components/chat/ExternallyConnectedTaskBanner";
import { TaskInternalNotes } from "../components/chat/TaskInternalNotes";
import { submitSharedThreadMessage } from "../components/chat/shared-thread-submit";
import {
IssueProperties,
type IssuePropertiesDocumentDeepLink,
@@ -1273,8 +1275,8 @@ type IssueDetailChatTabProps = {
clientRequestId?: string,
) => Promise<void>;
onReviewConversation: () => Promise<void>;
onImageUpload: (file: File) => Promise<string>;
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
onImageUpload?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
onInterruptQueued: (runId: string | null) => Promise<void>;
onDeleteComment?: (commentId: string) => Promise<void> | void;
onPauseWorkRun?: (runId: string, feedback?: "composer") => Promise<void>;
@@ -2988,6 +2990,10 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
enabled: !!issueId,
});
const issue = queriedIssue ?? conversation?.issue ?? draftIssue;
const chatBinding = useIssueChatBinding(issue?.companyId ?? "", issue?.id ?? "");
const sharedSlackThread = chatBinding.binding?.bindingMode === "slack_dm_thread_v2";
const [sharedSendNotice, setSharedSendNotice] = useState<string | null>(null);
useEffect(() => setSharedSendNotice(null), [issue?.id]);
const resolveWritableIssueId = async () => {
if (!conversation) return issueId!;
const resolved = await conversation.ensureIssue();
@@ -6195,6 +6201,23 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
attachmentIds?: string[],
clientRequestId?: string,
) => {
if (issue?.originKind === "chat_channel" && (chatBinding.isLoading || chatBinding.isError)) {
throw new Error("The message destination could not be confirmed. Refresh the task before sending.");
}
if (sharedSlackThread && chatBinding.binding) {
try {
if (reassignment) throw new Error("This Slack thread stays assigned to its CEO. Remove the reassignment before sending.");
const receipt = await submitSharedThreadMessage(chatBinding.binding, body, clientRequestId, attachmentIds);
setSharedSendNotice(receipt.status === "failed" ? "Request saved, but the CEO could not start. Check the connection before trying a new request." : "Request saved for Slack. The CEO starts after Slack confirms delivery.");
invalidateIssueThreadLazily();
invalidateIssueDetail();
invalidateIssueCollections();
} catch (error) {
setSharedSendNotice(error instanceof Error ? error.message : "The shared request could not be confirmed.");
throw error;
}
return;
}
if (reassignment) {
await addCommentAndReassign.mutateAsync({
body,
@@ -6207,7 +6230,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
}
await addComment.mutateAsync({ body, reopen, attachmentIds, clientRequestId });
},
[addComment, addCommentAndReassign],
[addComment, addCommentAndReassign, issue?.originKind, chatBinding, sharedSlackThread, invalidateIssueThreadLazily, invalidateIssueDetail, invalidateIssueCollections],
);
const handleCommentImageUpload = useCallback(
async (file: File) => {
@@ -7801,7 +7824,13 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
onRefreshLatestComments={refetchLatestComments}
composerRef={commentComposerRef}
composerAccessory={
hasVisibleMonitorSurface(issue) ? (
sharedSlackThread ? <div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Shared with Slack · text only</p>
{currentUserId && <TaskInternalNotes key={`${issue.id}:${currentUserId}`} issueId={issue.id} companyId={issue.companyId} userId={currentUserId} />}
</div>
{sharedSendNotice && <p role="status" className="text-xs text-muted-foreground">{sharedSendNotice}</p>}
</div> : hasVisibleMonitorSurface(issue) ? (
<IssueMonitorComposerStrip
issue={issue}
onCheckNow={() => checkIssueMonitorNow.mutate()}
@@ -7824,8 +7853,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
currentUserId={currentUserId}
userLabelMap={userLabelMap}
userProfileMap={userProfileMap}
draftKey={conversationAgent ? `paperclip:agent-chat-draft:${issue.companyId}:${currentUserId}:${conversationAgent.id}` : `paperclip:issue-comment-draft:${issue.id}`}
reassignOptions={commentReassignOptions}
draftKey={sharedSlackThread ? `paperclip:slack-shared-draft:v2:${issue.companyId}:${currentUserId}:${issue.id}:${chatBinding.binding!.conversationId}` : conversationAgent ? `paperclip:agent-chat-draft:${issue.companyId}:${currentUserId}:${conversationAgent.id}` : `paperclip:issue-comment-draft:${issue.id}`}
reassignOptions={sharedSlackThread ? [] : commentReassignOptions}
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
@@ -7841,7 +7870,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
} : undefined,
resumeHref: !activePauseHold.isRoot ? createIssueDetailPath(activePauseHoldRoot?.identifier ?? activePauseHold.rootIssueId) : undefined,
} : null}
composerDisabledReason={issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
composerDisabledReason={issue.originKind === "chat_channel" && (chatBinding.isLoading || chatBinding.isError) ? "Checking the message destination. Refresh if the connection cannot be loaded." : issue.conversationAgentId && !instanceExperimentalSettings?.enableAgentChat ? "Agent Chat is disabled in Experimental settings." : treeControlStateError ? "Couldn’t check whether this task is paused. Refresh to try again." : null}
composerHint={composerHint}
queuedCommentReason={queuedCommentReason}
onVote={handleCommentVote}
@@ -7855,8 +7884,8 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
),
]);
}}
onImageUpload={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
onImageUpload={sharedSlackThread ? undefined : handleCommentImageUpload}
onAttachImage={sharedSlackThread ? undefined : handleCommentAttachImage}
onInterruptQueued={handleInterruptQueuedRun}
onDeleteComment={(commentId) =>
deleteComment
+2
View File
@@ -158,6 +158,7 @@ export function Issues() {
workspaceIdFilter ?? "__all__",
"compact",
"with-routine-executions",
"with-idle-slack-conversations",
"infinite",
issuePageSize,
],
@@ -165,6 +166,7 @@ export function Issues() {
participantAgentId,
workspaceId: workspaceIdFilter,
includeRoutineExecutions: true,
includeIdleSlackConversations: true,
limit: issuePageSize,
offset: pageParam,
sortField: "updated",
@@ -0,0 +1,25 @@
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { TaskInternalNotes } from "@/components/chat/TaskInternalNotes";
function NotesPreview() {
const [client] = useState(() => {
const result = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false, refetchOnWindowFocus: false } } });
result.setQueryData(["human-internal-notes", "company", "person", "task"], {
pages: [{ notes: [{ id: "note", issueId: "task", authorUserId: "person", body: "Review the proposal with the team before authorizing implementation.", createdAt: "2026-09-23T07:00:00Z" }], nextCursor: null }], pageParams: [null],
});
return result;
});
return <QueryClientProvider client={client}><div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Shared with Slack · text only</p>
<TaskInternalNotes issueId="task" companyId="company" userId="person" />
</div></QueryClientProvider>;
}
export default { title: "Connections/Slack thread Internal notes", component: NotesPreview, parameters: { layout: "padded" } } satisfies Meta<typeof NotesPreview>;
type Story = StoryObj<typeof NotesPreview>;
export const Closed: Story = {};
export const HumanOnlyNotes: Story = { play: async ({ canvasElement }) => {
await userEvent.click(within(canvasElement).getByRole("button", { name: "Internal notes" }));
} };