Files
PaperClipAI/ui/src/hooks/usePaperclipIssueRuntime.ts
T
DottaandPaperclip 569c7203aa fix(ui): use latest issue runtime callbacks (#14863)
## Thinking Path

> - Paperclip lets people manage AI agents and their work.
> - The issue page uses an external-store adapter for comments.
> - The adapter must keep one identity during an unrelated render.
> - The adapter must also call the newest send and cancel functions.
> - Passive effects update those functions too late for a synchronous
runtime call.
> - This pull request updates the function refs during render and adds a
regression test.
> - The benefit is a stable comment thread with no stale comment action.

## Linked Issues or Issue Description

Refs #3678

**What happened?**

The issue comment runtime can call the prior send function after a
render. The callback refs do not update until passive effects run.

**Expected behavior**

The stable runtime adapter must call the newest function as soon as the
render supplies it.

**Steps to reproduce**

1. Render the issue runtime with one send function.
2. Render it again with a new send function and the same thread data.
3. Call the stable adapter before passive effects run.
4. Observe that the prior send function runs.

**Paperclip version or commit**

`6395cae072`

**Deployment mode**

Local development from source.

**Agent adapter(s) involved**

This is a core UI bug. It is not adapter-specific.

## What Changed

- Update the latest send and cancel refs during render.
- Keep the external-store adapter stable across callback-only renders.
- Add a test for a runtime callback before passive effects run.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/hooks/usePaperclipIssueRuntime.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`

## Risks

- Low risk. The change only updates two refs earlier in the same render.
- The adapter identity and its data dependencies do not change.

> This bug fix does not add or overlap with a roadmap feature.

## Model Used

- OpenAI Codex with GPT-5. The exact deployed model ID and context
window are not exposed. Reasoning, tool use, and local code execution
were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-03 06:52:49 -05:00

116 lines
3.2 KiB
TypeScript

import { useMemo, useRef } from "react";
import {
useExternalStoreRuntime,
type ThreadMessage,
type AppendMessage,
type ExternalStoreAdapter,
} from "@assistant-ui/react";
export interface PaperclipIssueRuntimeReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface PaperclipIssueRuntimeSendOptions {
body: string;
reopen?: boolean;
reassignment?: PaperclipIssueRuntimeReassignment;
attachmentIds?: string[];
}
interface UsePaperclipIssueRuntimeOptions {
messages: readonly ThreadMessage[];
isRunning: boolean;
onSend: (options: PaperclipIssueRuntimeSendOptions) => Promise<void>;
onCancel?: (() => Promise<void>) | undefined;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value))
return null;
return value as Record<string, unknown>;
}
function readTextContent(message: AppendMessage) {
return message.content
.filter(
(
part,
): part is Extract<(typeof message.content)[number], { type: "text" }> =>
part.type === "text",
)
.map((part) => part.text)
.join("")
.trim();
}
export function usePaperclipIssueRuntime({
messages,
isRunning,
onSend,
onCancel,
}: UsePaperclipIssueRuntimeOptions) {
const onSendRef = useRef(onSend);
const onCancelRef = useRef(onCancel);
onSendRef.current = onSend;
onCancelRef.current = onCancel;
const adapter = useMemo<ExternalStoreAdapter<ThreadMessage>>(
() => ({
messages,
isRunning,
onNew: async (message) => {
const body = readTextContent(message);
if (!body) return;
const custom = asRecord(message.runConfig?.custom);
const reassignmentRecord = asRecord(custom?.reassignment);
const reassignment =
reassignmentRecord &&
("assigneeAgentId" in reassignmentRecord ||
"assigneeUserId" in reassignmentRecord)
? {
assigneeAgentId:
typeof reassignmentRecord.assigneeAgentId === "string"
? reassignmentRecord.assigneeAgentId
: null,
assigneeUserId:
typeof reassignmentRecord.assigneeUserId === "string"
? reassignmentRecord.assigneeUserId
: null,
}
: undefined;
await onSendRef.current({
body,
reopen: custom?.reopen === true ? true : undefined,
reassignment,
...(Array.isArray(custom?.attachmentIds) &&
custom.attachmentIds.length > 0
? {
attachmentIds: [
...new Set(
custom.attachmentIds.filter(
(id): id is string =>
typeof id === "string" && id.length > 0,
),
),
],
}
: {}),
});
},
...(onCancel
? {
onCancel: async () => {
await onCancelRef.current?.();
},
}
: {}),
}),
[messages, isRunning, !!onCancel],
);
return useExternalStoreRuntime(adapter);
}