Merge pull request #3079 from paperclipai/PAP-1132-assistant-ui-pap-1131-make-issues-comments-be-like-a-chat

Polish issue chat UX and add worktree reseed
This commit is contained in:
Dotta 2026-04-08 06:10:04 -05:00 committed by GitHub
commit 667d5a7384
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 4810 additions and 63 deletions

View file

@ -13,6 +13,7 @@ import {
resolveWorktreeMakeTargetPath,
worktreeInitCommand,
worktreeMakeCommand,
worktreeReseedCommand,
} from "../commands/worktree.js";
import {
buildWorktreeConfig,
@ -481,6 +482,191 @@ describe("worktree helpers", () => {
}
});
it("requires an explicit source for worktree reseed", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-source-"));
const repoRoot = path.join(tempRoot, "repo");
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(repoRoot, { recursive: true });
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
await expect(worktreeReseedCommand({ seed: false, yes: true })).rejects.toThrow(
"Reseed requires an explicit source.",
);
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("reseed preserves the current worktree ports, instance id, and branding", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-"));
const repoRoot = path.join(tempRoot, "repo");
const sourceRoot = path.join(tempRoot, "source");
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
const currentInstanceId = "existing-worktree";
const currentPaths = resolveWorktreeLocalPaths({
cwd: repoRoot,
homeDir,
instanceId: currentInstanceId,
});
const sourcePaths = resolveWorktreeLocalPaths({
cwd: sourceRoot,
homeDir: path.join(tempRoot, ".paperclip-source"),
instanceId: "default",
});
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(sourceRoot, { recursive: true });
const currentConfig = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths: currentPaths,
serverPort: 3114,
databasePort: 54341,
});
const sourceConfig = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths: sourcePaths,
serverPort: 3200,
databasePort: 54400,
});
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
fs.writeFileSync(
currentPaths.envPath,
[
`PAPERCLIP_HOME=${homeDir}`,
`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`,
"PAPERCLIP_WORKTREE_NAME=existing-name",
"PAPERCLIP_WORKTREE_COLOR=\"#112233\"",
].join("\n"),
"utf8",
);
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
await worktreeReseedCommand({
fromConfig: sourcePaths.configPath,
seed: false,
yes: true,
});
const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
const rewrittenEnv = fs.readFileSync(currentPaths.envPath, "utf8");
expect(rewrittenConfig.server.port).toBe(3114);
expect(rewrittenConfig.database.embeddedPostgresPort).toBe(54341);
expect(rewrittenConfig.database.embeddedPostgresDataDir).toBe(currentPaths.embeddedPostgresDataDir);
expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name");
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\"");
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("restores the current worktree config and instance data if reseed fails", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-reseed-rollback-"));
const repoRoot = path.join(tempRoot, "repo");
const sourceRoot = path.join(tempRoot, "source");
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
const currentInstanceId = "rollback-worktree";
const currentPaths = resolveWorktreeLocalPaths({
cwd: repoRoot,
homeDir,
instanceId: currentInstanceId,
});
const sourcePaths = resolveWorktreeLocalPaths({
cwd: sourceRoot,
homeDir: path.join(tempRoot, ".paperclip-source"),
instanceId: "default",
});
const originalCwd = process.cwd();
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
try {
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
fs.mkdirSync(path.dirname(sourcePaths.configPath), { recursive: true });
fs.mkdirSync(currentPaths.instanceRoot, { recursive: true });
fs.mkdirSync(path.dirname(sourcePaths.secretsKeyFilePath), { recursive: true });
fs.mkdirSync(repoRoot, { recursive: true });
fs.mkdirSync(sourceRoot, { recursive: true });
const currentConfig = buildWorktreeConfig({
sourceConfig: buildSourceConfig(),
paths: currentPaths,
serverPort: 3114,
databasePort: 54341,
});
const sourceConfig = {
...buildSourceConfig(),
database: {
mode: "postgres",
connectionString: "",
},
secrets: {
provider: "local_encrypted",
strictMode: false,
localEncrypted: {
keyFilePath: sourcePaths.secretsKeyFilePath,
},
},
} as PaperclipConfig;
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
fs.writeFileSync(currentPaths.envPath, `PAPERCLIP_HOME=${homeDir}\nPAPERCLIP_INSTANCE_ID=${currentInstanceId}\n`, "utf8");
fs.writeFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "keep me", "utf8");
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
delete process.env.PAPERCLIP_CONFIG;
process.chdir(repoRoot);
await expect(worktreeReseedCommand({
fromConfig: sourcePaths.configPath,
yes: true,
})).rejects.toThrow("Source instance uses postgres mode but has no connection string");
const restoredConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
const restoredEnv = fs.readFileSync(currentPaths.envPath, "utf8");
const restoredMarker = fs.readFileSync(path.join(currentPaths.instanceRoot, "marker.txt"), "utf8");
expect(restoredConfig.server.port).toBe(3114);
expect(restoredConfig.database.embeddedPostgresPort).toBe(54341);
expect(restoredEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
expect(restoredMarker).toBe("keep me");
} finally {
process.chdir(originalCwd);
if (originalPaperclipConfig === undefined) {
delete process.env.PAPERCLIP_CONFIG;
} else {
process.env.PAPERCLIP_CONFIG = originalPaperclipConfig;
}
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rebinds same-repo workspace paths onto the current worktree root", () => {
expect(
rebindWorkspaceCwd({

View file

@ -80,6 +80,7 @@ import {
type WorktreeInitOptions = {
name?: string;
color?: string;
instance?: string;
home?: string;
fromConfig?: string;
@ -97,6 +98,22 @@ type WorktreeMakeOptions = WorktreeInitOptions & {
startPoint?: string;
};
type WorktreeReseedOptions = {
fromConfig?: string;
fromDataDir?: string;
fromInstance?: string;
home?: string;
seedMode?: string;
yes?: boolean;
seed?: boolean;
};
type WorktreeReseedBackup = {
tempRoot: string;
repoConfigDirBackup: string | null;
instanceRootBackup: string | null;
};
type WorktreeEnvOptions = {
config?: string;
json?: boolean;
@ -942,8 +959,8 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
instanceId,
});
const branding = {
name: worktreeName,
color: generateWorktreeColor(),
name: opts.name ?? worktreeName,
color: opts.color ?? generateWorktreeColor(),
};
const sourceConfigPath = resolveSourceConfigPath(opts);
const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null;
@ -1051,6 +1068,160 @@ export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<vo
await runWorktreeInit(opts);
}
function hasExplicitSourceSelection(opts: {
fromConfig?: string;
fromDataDir?: string;
fromInstance?: string;
sourceConfigPathOverride?: string;
}): boolean {
return Boolean(
nonEmpty(opts.fromConfig)
|| nonEmpty(opts.fromDataDir)
|| nonEmpty(opts.fromInstance)
|| nonEmpty(opts.sourceConfigPathOverride),
);
}
function resolveCurrentWorktreeReseedState(opts: { home?: string } = {}) {
const currentConfigPath = resolveConfigPath();
if (!existsSync(currentConfigPath)) {
throw new Error(
"Current directory does not have a Paperclip worktree config. Run `paperclipai worktree init` here first.",
);
}
const currentConfig = readConfig(currentConfigPath);
if (!currentConfig) {
throw new Error(`Could not read current worktree config at ${currentConfigPath}.`);
}
if (currentConfig.database.mode !== "embedded-postgres") {
throw new Error("Worktree reseed only supports embedded-postgres worktree instances.");
}
const currentEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(currentConfigPath));
const instanceRoot = path.dirname(currentConfig.database.embeddedPostgresDataDir);
const derivedHomeDir = path.dirname(path.dirname(instanceRoot));
return {
currentConfigPath: path.resolve(currentConfigPath),
instanceId:
nonEmpty(currentEnvEntries.PAPERCLIP_INSTANCE_ID)
?? nonEmpty(path.basename(instanceRoot))
?? sanitizeWorktreeInstanceId(path.basename(process.cwd())),
homeDir: path.resolve(expandHomePrefix(opts.home ?? currentEnvEntries.PAPERCLIP_HOME ?? derivedHomeDir)),
serverPort: currentConfig.server.port,
dbPort: currentConfig.database.embeddedPostgresPort,
worktreeName: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_NAME) ?? undefined,
worktreeColor: nonEmpty(currentEnvEntries.PAPERCLIP_WORKTREE_COLOR) ?? undefined,
};
}
async function snapshotDirectory(sourcePath: string, targetPath: string): Promise<string | null> {
if (!existsSync(sourcePath)) {
return null;
}
await fsPromises.cp(sourcePath, targetPath, { recursive: true });
return targetPath;
}
async function snapshotWorktreeReseedState(target: {
repoConfigDir: string;
instanceRoot: string;
}): Promise<WorktreeReseedBackup> {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-reseed-backup-"));
return {
tempRoot,
repoConfigDirBackup: await snapshotDirectory(
target.repoConfigDir,
path.resolve(tempRoot, "repo-config"),
),
instanceRootBackup: await snapshotDirectory(
target.instanceRoot,
path.resolve(tempRoot, "instance-root"),
),
};
}
async function restoreDirectoryBackup(backupPath: string | null, targetPath: string): Promise<void> {
rmSync(targetPath, { recursive: true, force: true });
if (!backupPath) {
return;
}
await fsPromises.cp(backupPath, targetPath, { recursive: true });
}
async function restoreWorktreeReseedState(
backup: WorktreeReseedBackup,
target: { repoConfigDir: string; instanceRoot: string },
): Promise<void> {
await restoreDirectoryBackup(backup.repoConfigDirBackup, target.repoConfigDir);
await restoreDirectoryBackup(backup.instanceRootBackup, target.instanceRoot);
}
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
if (!hasExplicitSourceSelection(opts)) {
throw new Error(
"Reseed requires an explicit source. Pass --from-config or --from-instance (optionally with --from-data-dir).",
);
}
const target = resolveCurrentWorktreeReseedState({ home: opts.home });
const sourceConfigPath = resolveSourceConfigPath(opts);
if (path.resolve(sourceConfigPath) === target.currentConfigPath) {
throw new Error(
"Source and target Paperclip configs are the same. Pass a different source instance/config when reseeding.",
);
}
const seedMode = opts.seedMode ?? "minimal";
if (!isWorktreeSeedMode(seedMode)) {
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
}
const confirmed = opts.yes
? true
: await p.confirm({
message: `Reseed the current worktree instance (${target.instanceId}) from ${sourceConfigPath}? This overwrites only the current worktree Paperclip instance data.`,
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
p.log.warn("Reseed cancelled.");
return;
}
const targetPaths = resolveWorktreeLocalPaths({
cwd: process.cwd(),
homeDir: target.homeDir,
instanceId: target.instanceId,
});
const backup = await snapshotWorktreeReseedState(targetPaths);
try {
await runWorktreeInit({
name: target.worktreeName,
color: target.worktreeColor,
instance: target.instanceId,
home: target.homeDir,
fromConfig: opts.fromConfig,
fromDataDir: opts.fromDataDir,
fromInstance: opts.fromInstance,
sourceConfigPathOverride: sourceConfigPath,
serverPort: target.serverPort,
dbPort: target.dbPort,
seed: opts.seed ?? true,
seedMode,
force: true,
});
} catch (error) {
await restoreWorktreeReseedState(backup, targetPaths);
throw error;
} finally {
rmSync(backup.tempRoot, { recursive: true, force: true });
}
}
export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> {
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make ")));
@ -2632,6 +2803,17 @@ export function registerWorktreeCommands(program: Command): void {
.option("--json", "Print JSON instead of shell exports")
.action(worktreeEnvCommand);
worktree
.command("reseed")
.description("Replace the current worktree instance with a fresh seed while preserving this worktree's ports and instance id")
.option("--from-config <path>", "Source config.json to seed from")
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
.option("--from-instance <id>", "Source instance id when deriving the source config")
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--yes", "Skip the destructive confirmation prompt", false)
.action(worktreeReseedCommand);
program
.command("worktree:list")
.description("List git worktrees visible from this repo and whether they look like Paperclip worktrees")

View file

@ -232,6 +232,15 @@ pnpm paperclipai worktree init --force --seed-mode minimal \
That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`, and preserves the git worktree contents themselves.
For existing worktrees, prefer the dedicated reseed command instead of rebuilding the `worktree init --force` flags manually:
```sh
cd /path/to/existing/worktree
pnpm paperclipai worktree reseed --from-config /path/to/source/.paperclip/config.json --seed-mode full
```
`worktree reseed` preserves the current worktree's instance id, ports, and branding while replacing only that worktree's isolated Paperclip instance data from the chosen source.
**`pnpm paperclipai worktree:make <name> [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step.
| Option | Description |
@ -258,6 +267,17 @@ pnpm paperclipai worktree:make experiment --no-seed
**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance.
**`pnpm paperclipai worktree reseed [options]`** — Replace the current worktree instance with a fresh seed from another Paperclip source while preserving the current worktree's ports and instance id.
| Option | Description |
|---|---|
| `--from-config <path>` | Source config.json to seed from |
| `--from-data-dir <path>` | Source `PAPERCLIP_HOME` used when deriving the source config |
| `--from-instance <id>` | Source instance id when deriving the source config |
| `--home <path>` | Home root for worktree instances (default: `~/.paperclip-worktrees`) |
| `--seed-mode <mode>` | Seed profile: `minimal` or `full` (default: `minimal`) |
| `--yes` | Skip the destructive confirmation prompt |
| Option | Description |
|---|---|
| `-c, --config <path>` | Path to config file |

View file

@ -26,6 +26,7 @@
"access": "public"
},
"dependencies": {
"@assistant-ui/react": "0.12.23",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",

View file

@ -36,6 +36,7 @@ import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { AdapterManager } from "./pages/AdapterManager";
import { PluginPage } from "./pages/PluginPage";
import { IssueChatUxLab } from "./pages/IssueChatUxLab";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
@ -175,6 +176,7 @@ function boardRoutes() {
<Route path="inbox/all" element={<Inbox />} />
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path="instance/settings/adapters" element={<AdapterManager />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
@ -347,6 +349,7 @@ export function App() {
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}

View file

@ -3,13 +3,13 @@ import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils";
import { ExternalLink } from "lucide-react";
import { Identity } from "./Identity";
import { RunTranscriptView } from "./transcript/RunTranscriptView";
import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
const MIN_DASHBOARD_RUNS = 4;
@ -63,6 +63,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
{runs.map((run) => (
<AgentRunCard
key={run.id}
companyId={companyId}
run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined}
transcript={transcriptByRun.get(run.id) ?? []}
@ -77,12 +78,14 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
}
function AgentRunCard({
companyId,
run,
issue,
transcript,
hasOutput,
isActive,
}: {
companyId: string;
run: LiveRunForIssue;
issue?: Issue;
transcript: TranscriptEntry[];
@ -141,14 +144,11 @@ function AgentRunCard({
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunTranscriptView
entries={transcript}
density="compact"
limit={5}
streaming={isActive}
collapseStdout
thinkingClassName="!text-[10px] !leading-4"
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutput}
companyId={companyId}
/>
</div>
</div>

View file

@ -0,0 +1,262 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ThreadPrimitive: {
Root: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="thread-root" className={className}>{children}</div>
),
Viewport: ({ children, className }: { children: ReactNode; className?: string }) => (
<div data-testid="thread-viewport" className={className}>{children}</div>
),
Empty: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Messages: () => <div data-testid="thread-messages" />,
},
MessagePrimitive: {
Root: ({ children }: { children: ReactNode }) => <div>{children}</div>,
Content: () => null,
Parts: () => null,
},
useAui: () => ({ thread: () => ({ append: vi.fn() }) }),
useAuiState: () => false,
useMessage: () => ({
id: "message",
role: "assistant",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
content: [],
metadata: { custom: {} },
status: { type: "complete" },
}),
}));
vi.mock("./transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({
transcriptByRun: new Map(),
hasOutputForRun: () => false,
}),
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: ({
value = "",
onChange,
placeholder,
}: {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
}) => (
<textarea
aria-label="Issue chat editor"
placeholder={placeholder}
value={value}
onChange={(event) => onChange?.(event.target.value)}
/>
),
}));
vi.mock("./InlineEntitySelector", () => ({
InlineEntitySelector: () => null,
}));
vi.mock("./Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
vi.mock("./OutputFeedbackButtons", () => ({
OutputFeedbackButtons: () => null,
}));
vi.mock("./AgentIconPicker", () => ({
AgentIcon: () => null,
}));
vi.mock("./StatusBadge", () => ({
StatusBadge: ({ status }: { status: string }) => <span>{status}</span>,
}));
vi.mock("../hooks/usePaperclipIssueRuntime", () => ({
usePaperclipIssueRuntime: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("IssueChatThread", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
localStorage.clear();
});
afterEach(() => {
container.remove();
vi.useRealTimers();
});
it("drops the count heading and does not use an internal scrollbox", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Jump to latest");
expect(container.textContent).not.toContain("Chat (");
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
expect(viewport).not.toBeNull();
expect(viewport?.className).not.toContain("overflow-y-auto");
expect(viewport?.className).not.toContain("max-h-[70vh]");
act(() => {
root.unmount();
});
});
it("supports the embedded read-only variant without the jump control", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
variant="embedded"
emptyMessage="No run output captured."
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("No run output captured.");
expect(container.textContent).not.toContain("Jump to latest");
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
expect(viewport?.className).toContain("space-y-3");
act(() => {
root.unmount();
});
});
it("stores and restores the composer draft per issue key", () => {
vi.useFakeTimers();
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
draftKey="issue-chat-draft:test-1"
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
expect(editor).not.toBeNull();
expect(editor?.placeholder).toBe("Reply");
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(editor, "Draft survives refresh");
editor?.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(900);
});
expect(localStorage.getItem("issue-chat-draft:test-1")).toBe("Draft survives refresh");
act(() => {
root.unmount();
});
const remount = createRoot(container);
act(() => {
remount.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
draftKey="issue-chat-draft:test-1"
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
const restoredEditor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null;
expect(restoredEditor?.value).toBe("Draft survives refresh");
act(() => {
remount.unmount();
});
});
it("folds chain-of-thought when the same message transitions from running to complete", () => {
expect(resolveAssistantMessageFoldedState({
messageId: "message-1",
currentFolded: false,
isFoldable: true,
previousMessageId: "message-1",
previousIsFoldable: false,
})).toBe(true);
});
it("preserves a manually opened completed message across rerenders", () => {
expect(resolveAssistantMessageFoldedState({
messageId: "message-1",
currentFolded: false,
isFoldable: true,
previousMessageId: "message-1",
previousIsFoldable: true,
})).toBe(false);
});
});

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,8 @@ import { queryKeys } from "../lib/queryKeys";
import { formatDateTime } from "../lib/utils";
import { ExternalLink, Square } from "lucide-react";
import { Identity } from "./Identity";
import { RunChatSurface } from "./RunChatSurface";
import { StatusBadge } from "./StatusBadge";
import { RunTranscriptView } from "./transcript/RunTranscriptView";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
interface LiveRunWidgetProps {
@ -93,7 +93,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
Live Runs
</div>
<div className="mt-1 text-xs text-muted-foreground">
Streamed with the same transcript UI used on the full run detail page.
Uses the shared chat-style run surface from issue activity.
</div>
</div>
@ -142,13 +142,11 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
</div>
<div className="max-h-[320px] overflow-y-auto pr-1">
<RunTranscriptView
entries={transcript}
density="compact"
limit={8}
streaming={isActive}
collapseStdout
emptyMessage={hasOutputForRun(run.id) ? "Waiting for transcript parsing..." : "Waiting for run output..."}
<RunChatSurface
run={run}
transcript={transcript}
hasOutput={hasOutputForRun(run.id)}
companyId={companyId}
/>
</div>
</section>

View file

@ -8,6 +8,7 @@ import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chi
interface MarkdownBodyProps {
children: string;
className?: string;
style?: React.CSSProperties;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
}
@ -91,7 +92,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
);
}
export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownBodyProps) {
export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) {
const { theme } = useTheme();
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
@ -145,6 +146,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
theme === "dark" && "prose-invert",
className,
)}
style={style}
>
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
{children}

View file

@ -20,6 +20,7 @@ export function OutputFeedbackButtons({
termsUrl = null,
onVote,
rightSlot,
inline = false,
}: {
activeVote?: FeedbackVoteValue | null;
disabled?: boolean;
@ -27,6 +28,7 @@ export function OutputFeedbackButtons({
termsUrl?: string | null;
onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise<void>;
rightSlot?: React.ReactNode;
inline?: boolean;
}) {
const [pendingVote, setPendingVote] = useState<{
vote: FeedbackVoteValue;
@ -109,7 +111,10 @@ export function OutputFeedbackButtons({
return (
<>
<div className="mt-3 flex items-center gap-2 border-t border-border/60 pt-3">
<div className={cn(
"flex items-center gap-2",
inline ? "justify-end" : "mt-3 border-t border-border/60 pt-3",
)}>
<Button
type="button"
size="sm"

View file

@ -0,0 +1,64 @@
import { useMemo } from "react";
import type { TranscriptEntry } from "../adapters";
import type { LiveRunForIssue } from "../api/heartbeats";
import { IssueChatThread } from "./IssueChatThread";
import type { IssueChatLinkedRun } from "../lib/issue-chat-messages";
function isRunActive(run: LiveRunForIssue) {
return run.status === "queued" || run.status === "running";
}
interface RunChatSurfaceProps {
run: LiveRunForIssue;
transcript: TranscriptEntry[];
hasOutput: boolean;
companyId?: string | null;
}
export function RunChatSurface({
run,
transcript,
hasOutput,
companyId,
}: RunChatSurfaceProps) {
const active = isRunActive(run);
const liveRuns = active ? [run] : [];
const linkedRuns = useMemo<IssueChatLinkedRun[]>(
() =>
active
? []
: [{
runId: run.id,
status: run.status,
agentId: run.agentId,
agentName: run.agentName,
createdAt: run.createdAt,
startedAt: run.startedAt,
finishedAt: run.finishedAt,
}],
[active, run],
);
const transcriptsByRunId = useMemo(
() => new Map([[run.id, transcript as readonly TranscriptEntry[]]]),
[run.id, transcript],
);
return (
<IssueChatThread
comments={[]}
linkedRuns={linkedRuns}
timelineEvents={[]}
liveRuns={liveRuns}
companyId={companyId}
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
variant="embedded"
emptyMessage={active ? "Waiting for run output..." : "No run output captured."}
enableLiveTranscriptPolling={false}
transcriptsByRunId={transcriptsByRunId}
hasOutputForRun={(runId) => runId === run.id && hasOutput}
includeSucceededRunsWithoutOutput
/>
);
}

View file

@ -2,15 +2,21 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
import { heartbeatsApi } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
export interface RunTranscriptSource {
id: string;
status: string;
adapterType: string;
}
interface UseLiveRunTranscriptsOptions {
runs: LiveRunForIssue[];
runs: RunTranscriptSource[];
companyId?: string | null;
maxChunksPerRun?: number;
}
@ -141,7 +147,7 @@ export function useLiveRunTranscripts({
let cancelled = false;
const readRunLog = async (run: LiveRunForIssue) => {
const readRunLog = async (run: RunTranscriptSource) => {
const offset = logOffsetByRunRef.current.get(run.id) ?? 0;
try {
const result = await heartbeatsApi.log(run.id, offset, LOG_READ_LIMIT_BYTES);
@ -166,13 +172,16 @@ export function useLiveRunTranscripts({
};
void readAll();
const interval = window.setInterval(() => {
void readAll();
}, LOG_POLL_INTERVAL_MS);
const activeRuns = runs.filter((run) => !isTerminalStatus(run.status));
const interval = activeRuns.length > 0
? window.setInterval(() => {
void Promise.all(activeRuns.map((run) => readRunLog(run)));
}, LOG_POLL_INTERVAL_MS)
: null;
return () => {
cancelled = true;
window.clearInterval(interval);
if (interval !== null) window.clearInterval(interval);
};
}, [runIdsKey, runs]);

View file

@ -0,0 +1,337 @@
import type { Agent, FeedbackVote } from "@paperclipai/shared";
import type { LiveRunForIssue } from "../api/heartbeats";
import type { InlineEntityOption } from "../components/InlineEntitySelector";
import type { MentionOption } from "../components/MarkdownEditor";
import type {
IssueChatComment,
IssueChatLinkedRun,
IssueChatTranscriptEntry,
} from "../lib/issue-chat-messages";
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
function createAgent(
id: string,
name: string,
icon: string,
urlKey: string,
): Agent {
const now = new Date("2026-04-06T12:00:00.000Z");
return {
id,
companyId: "company-ux",
name,
urlKey,
role: "engineer",
title: null,
icon,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
metadata: null,
createdAt: now,
updatedAt: now,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
};
}
function createComment(overrides: Partial<IssueChatComment>): IssueChatComment {
return {
id: "comment-default",
companyId: "company-ux",
issueId: "issue-ux",
authorAgentId: null,
authorUserId: "user-1",
body: "",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
const primaryAgent = createAgent("agent-1", "CodexCoder", "code", "codexcoder");
const reviewAgent = createAgent("agent-2", "ClaudeFixer", "sparkles", "claudefixer");
export const issueChatUxAgentMap = new Map<string, Agent>([
[primaryAgent.id, primaryAgent],
[reviewAgent.id, reviewAgent],
]);
export const issueChatUxMentions: MentionOption[] = [
{
id: "mention-agent-1",
name: primaryAgent.name,
kind: "agent",
agentId: primaryAgent.id,
agentIcon: primaryAgent.icon,
},
{
id: "mention-agent-2",
name: reviewAgent.name,
kind: "agent",
agentId: reviewAgent.id,
agentIcon: reviewAgent.icon,
},
{
id: "mention-project-1",
name: "Paperclip Board UI",
kind: "project",
projectId: "project-1",
projectColor: "#0f766e",
},
];
export const issueChatUxReassignOptions: InlineEntityOption[] = [
{
id: `agent:${primaryAgent.id}`,
label: primaryAgent.name,
searchText: `${primaryAgent.name} codex engineer`,
},
{
id: `agent:${reviewAgent.id}`,
label: reviewAgent.name,
searchText: `${reviewAgent.name} claude reviewer`,
},
{
id: "user:user-1",
label: "Board",
searchText: "board user",
},
];
export const issueChatUxLiveComments: IssueChatComment[] = [
createComment({
id: "comment-live-user",
body: "Ship the issue page as a real chat. Keep the activity feed, but make the assistant flow feel conversational.",
createdAt: new Date("2026-04-06T11:55:00.000Z"),
updatedAt: new Date("2026-04-06T11:55:00.000Z"),
}),
createComment({
id: "comment-live-agent",
authorAgentId: primaryAgent.id,
authorUserId: null,
body: "I swapped the old comment stack for the new assistant-ui thread and kept the existing issue mutations intact.",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
runId: "run-history-1",
runAgentId: primaryAgent.id,
}),
createComment({
id: "comment-live-queued",
body: "Can you also make a dedicated review page that shows every chat state side by side?",
createdAt: new Date("2026-04-06T12:05:30.000Z"),
updatedAt: new Date("2026-04-06T12:05:30.000Z"),
clientId: "client-queued-1",
clientStatus: "queued",
queueState: "queued",
queueTargetRunId: "run-live-1",
}),
];
export const issueChatUxLiveEvents: IssueTimelineEvent[] = [
{
id: "event-live-1",
createdAt: new Date("2026-04-06T11:54:00.000Z"),
actorType: "user",
actorId: "user-1",
statusChange: {
from: "done",
to: "todo",
},
},
{
id: "event-live-2",
createdAt: new Date("2026-04-06T11:54:30.000Z"),
actorType: "user",
actorId: "user-1",
assigneeChange: {
from: { agentId: null, userId: null },
to: { agentId: primaryAgent.id, userId: null },
},
},
];
export const issueChatUxLiveRuns: LiveRunForIssue[] = [
{
id: "run-live-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:04:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:04:00.000Z",
agentId: primaryAgent.id,
agentName: primaryAgent.name,
adapterType: "codex_local",
issueId: "issue-ux",
},
];
export const issueChatUxLinkedRuns: IssueChatLinkedRun[] = [
{
runId: "run-history-1",
status: "succeeded",
agentId: primaryAgent.id,
createdAt: new Date("2026-04-06T11:58:00.000Z"),
startedAt: new Date("2026-04-06T11:58:00.000Z"),
finishedAt: new Date("2026-04-06T12:00:00.000Z"),
},
{
runId: "run-review-1",
status: "failed",
agentId: reviewAgent.id,
createdAt: new Date("2026-04-06T12:31:00.000Z"),
startedAt: new Date("2026-04-06T12:31:00.000Z"),
finishedAt: new Date("2026-04-06T12:33:00.000Z"),
},
];
export const issueChatUxTranscriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
[
"run-history-1",
[
{
kind: "thinking",
ts: "2026-04-06T11:58:03.000Z",
text: "Reviewing the issue thread to see where transcript noise still leaks into the conversation.",
},
{
kind: "tool_call",
ts: "2026-04-06T11:58:07.000Z",
name: "read_file",
toolUseId: "tool-history-1",
input: { path: "ui/src/lib/issue-chat-messages.ts" },
},
{
kind: "tool_result",
ts: "2026-04-06T11:58:11.000Z",
toolUseId: "tool-history-1",
content: "Found the run projection path that decides whether transcript output survives after completion.",
isError: false,
},
{
kind: "assistant",
ts: "2026-04-06T11:59:24.000Z",
text: "Kept the completed run context attached to the chat timeline so the reasoning can stay folded instead of disappearing.",
},
],
],
[
"run-live-1",
[
{
kind: "assistant",
ts: "2026-04-06T12:04:02.000Z",
text: "I am reshaping the issue page so the thread reads like a conversation instead of a run log.",
},
{
kind: "thinking",
ts: "2026-04-06T12:04:05.000Z",
text: "Need to remove the internal scrollbox first, otherwise the page still feels like a nested console.",
},
{
kind: "tool_call",
ts: "2026-04-06T12:04:08.000Z",
name: "read_file",
toolUseId: "tool-read-1",
input: { path: "ui/src/components/IssueChatThread.tsx" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:04:11.000Z",
toolUseId: "tool-read-1",
content: "Loaded the current chat surface and found the max-h viewport constraint.",
isError: false,
},
{
kind: "tool_call",
ts: "2026-04-06T12:04:14.000Z",
name: "apply_patch",
toolUseId: "tool-edit-1",
input: { file: "ui/src/components/IssueChatThread.tsx", action: "remove scroll pane" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:04:22.000Z",
toolUseId: "tool-edit-1",
content: "Updated layout classes and swapped Jump to latest to page-level scrolling.",
isError: false,
},
{
kind: "stderr",
ts: "2026-04-06T12:04:24.000Z",
text: "vite warm-up: rebuilding route chunks",
},
],
],
]);
export const issueChatUxReviewComments: IssueChatComment[] = [
createComment({
id: "comment-review-user",
body: "This looks close. Tighten the spacing and keep the composer grounded to the chat surface.",
createdAt: new Date("2026-04-06T12:28:00.000Z"),
updatedAt: new Date("2026-04-06T12:28:00.000Z"),
}),
createComment({
id: "comment-review-agent",
authorAgentId: reviewAgent.id,
authorUserId: null,
body: [
"Adjusted the treatment to feel more like a product conversation.",
"",
"- Removed the count from the heading",
"- Let the page own scrolling",
"- Added a dedicated `/tests/ux/chat` review page",
].join("\n"),
createdAt: new Date("2026-04-06T12:34:00.000Z"),
updatedAt: new Date("2026-04-06T12:34:00.000Z"),
runId: "run-review-1",
runAgentId: reviewAgent.id,
}),
createComment({
id: "comment-review-user-followup",
body: "Perfect. I also want to see an empty state and a blocked composer state before we merge.",
createdAt: new Date("2026-04-06T12:36:00.000Z"),
updatedAt: new Date("2026-04-06T12:36:00.000Z"),
}),
];
export const issueChatUxReviewEvents: IssueTimelineEvent[] = [
{
id: "event-review-1",
createdAt: new Date("2026-04-06T12:27:00.000Z"),
actorType: "user",
actorId: "user-1",
assigneeChange: {
from: { agentId: primaryAgent.id, userId: null },
to: { agentId: reviewAgent.id, userId: null },
},
},
];
export const issueChatUxFeedbackVotes: FeedbackVote[] = [
{
id: "feedback-1",
companyId: "company-ux",
issueId: "issue-ux",
targetType: "issue_comment",
targetId: "comment-review-agent",
authorUserId: "user-1",
vote: "up",
reason: null,
sharedWithLabs: false,
sharedAt: null,
consentVersion: null,
redactionSummary: null,
createdAt: new Date("2026-04-06T12:35:00.000Z"),
updatedAt: new Date("2026-04-06T12:35:00.000Z"),
},
];

View file

@ -0,0 +1,68 @@
import { useExternalStoreRuntime, type ThreadMessage, type AppendMessage } from "@assistant-ui/react";
export interface PaperclipIssueRuntimeReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
export interface PaperclipIssueRuntimeSendOptions {
body: string;
reopen?: boolean;
reassignment?: PaperclipIssueRuntimeReassignment;
}
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) {
return useExternalStoreRuntime({
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 onSend({
body,
reopen: custom?.reopen === true ? true : undefined,
reassignment,
});
},
...(onCancel ? { onCancel } : {}),
});
}

View file

@ -266,6 +266,64 @@
}
}
/* Chain-of-thought reasoning line ticker animations.
Pure translate, no opacity the overflow-hidden container clips.
Both keyframes share the same easing so the two spans move in lockstep. */
@keyframes cot-line-slide-in {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes cot-line-slide-out {
from { transform: translateY(0); }
to { transform: translateY(-100%); }
}
.cot-line-enter {
animation: cot-line-slide-in 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
.cot-line-exit {
animation: cot-line-slide-out 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.cot-line-enter,
.cot-line-exit {
animation: none;
}
}
/* Shimmer text effect for active "Working" state */
@keyframes shimmer-text-slide {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
.shimmer-text {
--shimmer-base: hsl(var(--foreground) / 0.75);
--shimmer-highlight: hsl(var(--foreground) / 0.3);
background: linear-gradient(
110deg,
var(--shimmer-base) 35%,
var(--shimmer-highlight) 50%,
var(--shimmer-base) 65%
);
background-size: 250% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shimmer-text-slide 2.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.shimmer-text {
animation: none;
-webkit-text-fill-color: unset;
background: none;
}
}
/* MDXEditor theme integration */
.paperclip-mdxeditor-scope,
.paperclip-mdxeditor {

View file

@ -47,7 +47,7 @@ describe("assignee selection helpers", () => {
});
it("formats current and board user labels consistently", () => {
expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("Me");
expect(formatAssigneeUserLabel("user-1", "user-1")).toBe("You");
expect(formatAssigneeUserLabel("local-board", "someone-else")).toBe("Board");
expect(formatAssigneeUserLabel("user-abcdef", "someone-else")).toBe("user-");
});

View file

@ -76,7 +76,7 @@ export function formatAssigneeUserLabel(
currentUserId: string | null | undefined,
): string | null {
if (!userId) return null;
if (currentUserId && userId === currentUserId) return "Me";
if (currentUserId && userId === currentUserId) return "You";
if (userId === "local-board") return "Board";
return userId.slice(0, 5);
}

View file

@ -0,0 +1,370 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@paperclipai/shared";
import {
buildAssistantPartsFromTranscript,
buildIssueChatMessages,
type IssueChatComment,
type IssueChatLinkedRun,
} from "./issue-chat-messages";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import type { LiveRunForIssue } from "../api/heartbeats";
function createAgent(id: string, name: string): Agent {
return {
id,
companyId: "company-1",
name,
role: "engineer",
title: null,
icon: "code",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
metadata: {},
createdAt: new Date(),
updatedAt: new Date(),
pauseReason: null,
pausedAt: null,
urlKey: "codexcoder",
permissions: { canCreateAgents: false },
} as Agent;
}
function createComment(overrides: Partial<IssueChatComment> = {}): IssueChatComment {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorAgentId: null,
authorUserId: "user-1",
body: "Hello",
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
...overrides,
};
}
describe("buildAssistantPartsFromTranscript", () => {
it("maps assistant text, reasoning, and tool activity while omitting noisy stderr", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Working on it. " },
{ kind: "assistant", ts: "2026-04-06T12:00:01.000Z", text: "Done." },
{ kind: "thinking", ts: "2026-04-06T12:00:02.000Z", text: "Need to inspect files." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:03.000Z",
name: "read_file",
toolUseId: "tool-1",
input: { path: "ui/src/pages/IssueDetail.tsx" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:04.000Z",
toolUseId: "tool-1",
content: "file contents",
isError: false,
},
{ kind: "stderr", ts: "2026-04-06T12:00:05.000Z", text: "warn: noisy setup output" },
]);
expect(result.parts).toHaveLength(3);
expect(result.parts[0]).toMatchObject({ type: "text", text: "Working on it. Done." });
expect(result.parts[1]).toMatchObject({ type: "reasoning", text: "Need to inspect files." });
expect(result.parts[2]).toMatchObject({
type: "tool-call",
toolCallId: "tool-1",
toolName: "read_file",
result: "file contents",
isError: false,
});
expect(result.notices).toEqual([]);
});
it("preserves transcript ordering when text and tool activity are interleaved", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "First." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:01.000Z",
name: "read_file",
toolUseId: "tool-1",
input: { path: "ui/src/components/IssueChatThread.tsx" },
},
{ kind: "assistant", ts: "2026-04-06T12:00:02.000Z", text: "Second." },
{
kind: "tool_result",
ts: "2026-04-06T12:00:03.000Z",
toolUseId: "tool-1",
content: "ok",
isError: false,
},
{ kind: "thinking", ts: "2026-04-06T12:00:04.000Z", text: "Need one more check." },
{
kind: "tool_call",
ts: "2026-04-06T12:00:05.000Z",
name: "write_file",
toolUseId: "tool-2",
input: { path: "ui/src/lib/issue-chat-messages.ts" },
},
{
kind: "tool_result",
ts: "2026-04-06T12:00:06.000Z",
toolUseId: "tool-2",
content: "saved",
isError: false,
},
]);
expect(result.parts).toMatchObject([
{ type: "text", text: "First." },
{ type: "tool-call", toolCallId: "tool-1", toolName: "read_file", result: "ok" },
{ type: "text", text: "Second." },
{ type: "reasoning", text: "Need one more check." },
{ type: "tool-call", toolCallId: "tool-2", toolName: "write_file", result: "saved" },
]);
});
it("keeps run errors while suppressing init and system transcript noise", () => {
const result = buildAssistantPartsFromTranscript([
{
kind: "init",
ts: "2026-04-06T12:00:00.000Z",
model: "gpt-5.4",
sessionId: "session-123",
},
{
kind: "system",
ts: "2026-04-06T12:00:01.000Z",
text: "item started: planning_step (id=step-1)",
},
{
kind: "system",
ts: "2026-04-06T12:00:02.000Z",
text: "item completed: planning_step (id=step-1)",
},
{
kind: "result",
ts: "2026-04-06T12:00:03.000Z",
text: "Tool crashed during execution",
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
costUsd: 0,
subtype: "error",
isError: true,
errors: ["ENOENT: missing file"],
},
]);
expect(result.parts).toMatchObject([
{
type: "reasoning",
text: "Run error: ENOENT: missing file",
},
]);
expect(result.notices).toEqual([]);
});
it("preserves diff transcript output as a fenced diff block", () => {
const result = buildAssistantPartsFromTranscript([
{ kind: "assistant", ts: "2026-04-06T12:00:00.000Z", text: "Applied the patch." },
{ kind: "diff", ts: "2026-04-06T12:00:01.000Z", changeType: "file_header", text: "ui/src/lib/issue-chat-messages.ts" },
{ kind: "diff", ts: "2026-04-06T12:00:02.000Z", changeType: "add", text: "+function formatDiffBlock(lines: string[]) {" },
{ kind: "diff", ts: "2026-04-06T12:00:03.000Z", changeType: "add", text: "+ return ````diff`;" },
]);
expect(result.parts).toMatchObject([
{ type: "text", text: "Applied the patch." },
{
type: "text",
text: [
"```diff",
"ui/src/lib/issue-chat-messages.ts",
"+function formatDiffBlock(lines: string[]) {",
"+ return ````diff`;",
"```",
].join("\n"),
},
]);
});
});
describe("buildIssueChatMessages", () => {
it("orders events before comments and appends active live runs as running assistant messages", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const comments = [
createComment(),
createComment({
id: "comment-2",
authorAgentId: "agent-1",
authorUserId: null,
body: "I made the change.",
createdAt: new Date("2026-04-06T12:03:00.000Z"),
updatedAt: new Date("2026-04-06T12:03:00.000Z"),
runId: "run-1",
runAgentId: "agent-1",
}),
];
const timelineEvents: IssueTimelineEvent[] = [
{
id: "event-1",
createdAt: new Date("2026-04-06T11:59:00.000Z"),
actorType: "user",
actorId: "user-1",
statusChange: {
from: "done",
to: "todo",
},
},
];
const linkedRuns: IssueChatLinkedRun[] = [
{
runId: "run-history-1",
status: "succeeded",
agentId: "agent-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:02:00.000Z"),
},
];
const liveRuns: LiveRunForIssue[] = [
{
id: "run-live-1",
status: "running",
invocationSource: "manual",
triggerDetail: null,
startedAt: "2026-04-06T12:04:00.000Z",
finishedAt: null,
createdAt: "2026-04-06T12:04:00.000Z",
agentId: "agent-1",
agentName: "CodexCoder",
adapterType: "codex_local",
},
];
const messages = buildIssueChatMessages({
comments,
timelineEvents,
linkedRuns,
liveRuns,
transcriptsByRunId: new Map([
[
"run-live-1",
[{ kind: "assistant", ts: "2026-04-06T12:04:01.000Z", text: "Streaming reply" }],
],
]),
hasOutputForRun: (runId) => runId === "run-live-1",
companyId: "company-1",
projectId: "project-1",
agentMap,
currentUserId: "user-1",
});
expect(messages.map((message) => `${message.role}:${message.id}`)).toEqual([
"system:activity:event-1",
"user:comment-1",
"assistant:comment-2",
"assistant:live-run:run-live-1",
]);
const liveRunMessage = messages.at(-1);
expect(liveRunMessage).toMatchObject({
role: "assistant",
status: { type: "running" },
});
expect(liveRunMessage?.content[0]).toMatchObject({
type: "text",
text: "Streaming reply",
});
});
it("keeps succeeded runs as assistant messages when transcript output exists", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "CodexCoder")]]);
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-history-1",
status: "succeeded",
agentId: "agent-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
},
],
liveRuns: [],
transcriptsByRunId: new Map([
[
"run-history-1",
[
{ kind: "thinking", ts: "2026-04-06T12:01:10.000Z", text: "Checking the current issue thread." },
{ kind: "assistant", ts: "2026-04-06T12:02:30.000Z", text: "Updated the thread renderer." },
],
],
]),
hasOutputForRun: (runId) => runId === "run-history-1",
agentMap,
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "historical-run:run-history-1",
role: "assistant",
status: { type: "complete", reason: "stop" },
metadata: {
custom: {
kind: "historical-run",
runId: "run-history-1",
chainOfThoughtLabel: "Worked for 2 minutes",
},
},
});
expect(messages[0]?.content).toMatchObject([
{ type: "reasoning", text: "Checking the current issue thread." },
{ type: "text", text: "Updated the thread renderer." },
]);
});
it("can keep succeeded runs without transcript output for embedded run feeds", () => {
const messages = buildIssueChatMessages({
comments: [],
timelineEvents: [],
linkedRuns: [
{
runId: "run-history-2",
status: "succeeded",
agentId: "agent-1",
agentName: "CodexCoder",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
startedAt: new Date("2026-04-06T12:01:00.000Z"),
finishedAt: new Date("2026-04-06T12:03:00.000Z"),
},
],
liveRuns: [],
includeSucceededRunsWithoutOutput: true,
currentUserId: "user-1",
});
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
id: "run:run-history-2",
role: "system",
metadata: {
custom: {
kind: "run",
runId: "run-history-2",
runAgentName: "CodexCoder",
runStatus: "succeeded",
},
},
});
});
});

View file

@ -0,0 +1,727 @@
import type {
ReasoningMessagePart,
TextMessagePart,
ThreadAssistantMessage,
ThreadMessage,
ToolCallMessagePart,
ThreadSystemMessage,
ThreadUserMessage,
} from "@assistant-ui/react";
import type { Agent, IssueComment } from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { formatAssigneeUserLabel } from "./assignees";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import {
summarizeNotice,
} from "./transcriptPresentation";
type JsonValue = null | string | number | boolean | JsonValue[] | { [key: string]: JsonValue };
type JsonObject = { [key: string]: JsonValue };
export interface IssueChatComment extends IssueComment {
runId?: string | null;
runAgentId?: string | null;
interruptedRunId?: string | null;
clientId?: string;
clientStatus?: "pending" | "queued";
queueState?: "queued";
queueTargetRunId?: string | null;
}
export interface IssueChatLinkedRun {
runId: string;
status: string;
agentId: string;
agentName?: string;
createdAt: Date | string;
startedAt: Date | string | null;
finishedAt?: Date | string | null;
}
export interface IssueChatTranscriptEntry {
kind:
| "assistant"
| "thinking"
| "user"
| "tool_call"
| "tool_result"
| "init"
| "result"
| "stderr"
| "system"
| "stdout"
| "diff";
ts: string;
text?: string;
delta?: boolean;
name?: string;
input?: unknown;
toolUseId?: string;
toolName?: string;
content?: string;
isError?: boolean;
subtype?: string;
errors?: string[];
model?: string;
sessionId?: string;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
costUsd?: number;
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
}
type MessageWithOrder = {
createdAtMs: number;
order: number;
message: ThreadMessage;
};
function toDate(value: Date | string | null | undefined) {
return value instanceof Date ? value : new Date(value ?? Date.now());
}
function toTimestamp(value: Date | string | null | undefined) {
return toDate(value).getTime();
}
function sortByCreated<T extends { createdAt: Date | string; id: string }>(items: readonly T[]) {
return [...items].sort((a, b) => {
const diff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
if (diff !== 0) return diff;
return a.id.localeCompare(b.id);
});
}
function normalizeJsonValue(input: unknown): JsonValue {
if (
input === null ||
typeof input === "string" ||
typeof input === "number" ||
typeof input === "boolean"
) {
return input;
}
if (Array.isArray(input)) {
return input.map((entry) => normalizeJsonValue(entry));
}
if (typeof input === "object" && input) {
const entries = Object.entries(input as Record<string, unknown>).map(([key, value]) => [
key,
normalizeJsonValue(value),
]);
return Object.fromEntries(entries) as JsonObject;
}
return String(input);
}
function normalizeToolArgs(input: unknown): JsonObject {
if (typeof input === "object" && input && !Array.isArray(input)) {
return normalizeJsonValue(input) as JsonObject;
}
if (input === undefined) return {};
return { value: normalizeJsonValue(input) };
}
function stringifyUnknown(value: unknown) {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function mergePartText(
previous: TextMessagePart | ReasoningMessagePart,
next: TextMessagePart | ReasoningMessagePart,
) {
if (!previous.text) return next.text;
if (!next.text) return previous.text;
if (
previous.text.endsWith("\n")
|| next.text.startsWith("\n")
|| previous.text.endsWith(" ")
|| next.text.startsWith(" ")
) {
return `${previous.text}${next.text}`;
}
return previous.type === "text"
? `${previous.text} ${next.text}`
: `${previous.text}\n${next.text}`;
}
function formatDiffBlock(lines: string[]) {
return `\`\`\`diff\n${lines.join("\n")}\n\`\`\``;
}
function createAssistantMetadata(custom: Record<string, unknown>) {
return {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom,
} as const;
}
function authorNameForComment(
comment: IssueChatComment,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
) {
if (comment.authorAgentId) {
return agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8);
}
return formatAssigneeUserLabel(comment.authorUserId ?? null, currentUserId) ?? "You";
}
function formatStatusLabel(status: string) {
return status.replace(/_/g, " ");
}
function createCommentMessage(args: {
comment: IssueChatComment;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
companyId?: string | null;
projectId?: string | null;
}): ThreadMessage {
const { comment, agentMap, currentUserId, companyId, projectId } = args;
const createdAt = toDate(comment.createdAt);
const authorName = authorNameForComment(comment, agentMap, currentUserId);
const custom = {
kind: "comment",
commentId: comment.id,
anchorId: `comment-${comment.id}`,
authorName,
authorAgentId: comment.authorAgentId,
authorUserId: comment.authorUserId,
companyId: companyId ?? comment.companyId,
projectId: projectId ?? null,
runId: comment.runId ?? null,
runAgentId: comment.runAgentId ?? null,
clientStatus: comment.clientStatus ?? null,
queueState: comment.queueState ?? null,
queueTargetRunId: comment.queueTargetRunId ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
};
if (comment.authorAgentId) {
const message: ThreadAssistantMessage = {
id: comment.id,
role: "assistant",
createdAt,
content: [{ type: "text", text: comment.body }],
status: { type: "complete", reason: "stop" },
metadata: createAssistantMetadata(custom),
};
return message;
}
const message: ThreadUserMessage = {
id: comment.id,
role: "user",
createdAt,
content: [{ type: "text", text: comment.body }],
attachments: [],
metadata: { custom },
};
return message;
}
function createTimelineEventMessage(args: {
event: IssueTimelineEvent;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
}) {
const { event, agentMap, currentUserId } = args;
const actorName = event.actorType === "agent"
? (agentMap?.get(event.actorId)?.name ?? event.actorId.slice(0, 8))
: event.actorType === "system"
? "System"
: (formatAssigneeUserLabel(event.actorId, currentUserId) ?? "Board");
const lines: string[] = [`${actorName} updated this issue`];
if (event.statusChange) {
lines.push(
`Status: ${event.statusChange.from ?? "none"} -> ${event.statusChange.to ?? "none"}`,
);
}
if (event.assigneeChange) {
const from = event.assigneeChange.from.agentId
? (agentMap?.get(event.assigneeChange.from.agentId)?.name ?? event.assigneeChange.from.agentId.slice(0, 8))
: (formatAssigneeUserLabel(event.assigneeChange.from.userId, currentUserId) ?? "Unassigned");
const to = event.assigneeChange.to.agentId
? (agentMap?.get(event.assigneeChange.to.agentId)?.name ?? event.assigneeChange.to.agentId.slice(0, 8))
: (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId) ?? "Unassigned");
lines.push(`Assignee: ${from} -> ${to}`);
}
const message: ThreadSystemMessage = {
id: `activity:${event.id}`,
role: "system",
createdAt: toDate(event.createdAt),
content: [{ type: "text", text: lines.join("\n") }],
metadata: {
custom: {
kind: "event",
anchorId: `activity-${event.id}`,
eventId: event.id,
actorName,
actorType: event.actorType,
actorId: event.actorId,
statusChange: event.statusChange ?? null,
assigneeChange: event.assigneeChange ?? null,
},
},
};
return message;
}
function runTimestamp(run: IssueChatLinkedRun) {
return run.finishedAt ?? run.startedAt ?? run.createdAt;
}
export interface SegmentTiming {
startMs: number;
endMs: number;
}
function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): SegmentTiming[] {
const timings: SegmentTiming[] = [];
let inSegment = false;
let segStart = 0;
let segEnd = 0;
for (const entry of entries) {
const ts = new Date(entry.ts).getTime();
const isCoT =
entry.kind === "thinking" ||
entry.kind === "tool_call" ||
entry.kind === "tool_result" ||
entry.kind === "diff" ||
(entry.kind === "result" && ((entry.isError && !!entry.errors?.length) || !!entry.text));
const isText = entry.kind === "assistant" && !!entry.text;
if (isCoT) {
if (!inSegment) {
inSegment = true;
segStart = ts;
}
segEnd = ts;
} else if (isText && inSegment) {
timings.push({ startMs: segStart, endMs: segEnd });
inSegment = false;
}
}
if (inSegment) {
timings.push({ startMs: segStart, endMs: segEnd });
}
return timings;
}
export function formatDurationWords(ms: number | null) {
if (ms === null || !Number.isFinite(ms) || ms <= 0) return null;
const totalSeconds = Math.max(1, Math.round(ms / 1000));
if (totalSeconds < 60) {
return `${totalSeconds} second${totalSeconds === 1 ? "" : "s"}`;
}
const totalMinutes = Math.round(totalSeconds / 60);
if (totalMinutes < 60) {
return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) {
return `${hours} hour${hours === 1 ? "" : "s"}`;
}
return `${hours} hour${hours === 1 ? "" : "s"} ${minutes} minute${minutes === 1 ? "" : "s"}`;
}
function runDurationLabel(run: {
status: string;
createdAt: Date | string;
startedAt: Date | string | null;
finishedAt?: Date | string | null;
}) {
const start = run.startedAt ?? run.createdAt;
const end = run.finishedAt ?? null;
const durationMs = end ? Math.max(0, toTimestamp(end) - toTimestamp(start)) : null;
const durationText = formatDurationWords(durationMs);
switch (run.status) {
case "succeeded":
return durationText ? `Worked for ${durationText}` : "Finished work";
case "failed":
case "error":
return durationText ? `Failed after ${durationText}` : "Run failed";
case "timed_out":
return durationText ? `Timed out after ${durationText}` : "Run timed out";
case "cancelled":
return durationText ? `Cancelled after ${durationText}` : "Run cancelled";
case "queued":
return "Queued";
case "running":
return "Working...";
default:
return formatStatusLabel(run.status);
}
}
function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<string, Agent>) {
const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
const message: ThreadSystemMessage = {
id: `run:${run.runId}`,
role: "system",
createdAt: toDate(runTimestamp(run)),
content: [{ type: "text", text: `${agentName} run ${run.runId.slice(0, 8)} ${formatStatusLabel(run.status)}` }],
metadata: {
custom: {
kind: "run",
anchorId: `run-${run.runId}`,
runId: run.runId,
runAgentId: run.agentId,
runAgentName: agentName,
runStatus: run.status,
},
},
};
return message;
}
function createHistoricalTranscriptMessage(args: {
run: IssueChatLinkedRun;
transcript: readonly IssueChatTranscriptEntry[];
hasOutput: boolean;
agentMap?: Map<string, Agent>;
}) {
const { run, transcript, hasOutput, agentMap } = args;
const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const waitingText = hasOutput ? "" : "Run finished";
const content = parts.length > 0
? parts
: waitingText
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
: [];
const message: ThreadAssistantMessage = {
id: `historical-run:${run.runId}`,
role: "assistant",
createdAt: toDate(run.startedAt ?? run.createdAt),
content,
status: { type: "complete", reason: "stop" },
metadata: createAssistantMetadata({
kind: "historical-run",
anchorId: `run-${run.runId}`,
runId: run.runId,
runAgentId: run.agentId,
runAgentName: agentName,
runStatus: run.status,
notices,
waitingText,
chainOfThoughtLabel: runDurationLabel(run),
chainOfThoughtSegments: segments,
}),
};
return message;
}
export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]): {
parts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>>;
notices: string[];
segments: SegmentTiming[];
} {
const orderedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
const toolParts = new Map<string, ToolCallMessagePart<JsonObject, unknown>>();
const toolIndices = new Map<string, number>();
const notices: string[] = [];
let pendingDiffLines: string[] = [];
let pendingDiffParentId: string | undefined;
const flushPendingDiff = () => {
if (pendingDiffLines.length === 0) return;
orderedParts.push({
type: "text",
text: formatDiffBlock(pendingDiffLines),
parentId: pendingDiffParentId,
});
pendingDiffLines = [];
pendingDiffParentId = undefined;
};
for (const [index, entry] of entries.entries()) {
if (entry.kind === "diff") {
pendingDiffParentId ??= `diff-group:${index}`;
pendingDiffLines.push(entry.text ?? "");
continue;
}
flushPendingDiff();
if (entry.kind === "assistant" && entry.text) {
orderedParts.push({ type: "text", text: entry.text });
continue;
}
if (entry.kind === "thinking" && entry.text) {
orderedParts.push({ type: "reasoning", text: entry.text });
continue;
}
if (entry.kind === "tool_call") {
const toolCallId = entry.toolUseId || `tool-${index}`;
const nextPart: ToolCallMessagePart<JsonObject, unknown> = {
type: "tool-call",
toolCallId,
toolName: entry.name || "tool",
args: normalizeToolArgs(entry.input),
argsText: stringifyUnknown(entry.input),
};
if (!toolParts.has(toolCallId)) {
toolIndices.set(toolCallId, orderedParts.length);
orderedParts.push(nextPart);
} else {
const existingIndex = toolIndices.get(toolCallId);
if (existingIndex !== undefined) {
orderedParts[existingIndex] = nextPart;
}
}
toolParts.set(toolCallId, nextPart);
continue;
}
if (entry.kind === "tool_result") {
const toolCallId = entry.toolUseId || `tool-result-${index}`;
const existing = toolParts.get(toolCallId);
const nextPart: ToolCallMessagePart<JsonObject, unknown> = {
type: "tool-call",
toolCallId,
toolName: existing?.toolName || entry.toolName || "tool",
args: existing?.args ?? {},
argsText: existing?.argsText ?? "",
result: entry.content ?? "",
isError: entry.isError === true,
};
if (existing) {
const existingIndex = toolIndices.get(toolCallId);
if (existingIndex !== undefined) {
orderedParts[existingIndex] = nextPart;
}
} else {
toolIndices.set(toolCallId, orderedParts.length);
orderedParts.push(nextPart);
}
toolParts.set(toolCallId, nextPart);
continue;
}
if (entry.kind === "init") continue;
if (entry.kind === "stderr") continue;
if (entry.kind === "stdout") continue;
if (entry.kind === "system") continue;
if (entry.kind === "result") {
if (entry.isError && entry.errors?.length) {
for (const error of entry.errors) {
orderedParts.push({ type: "reasoning", text: `Run error: ${summarizeNotice(error)}` });
}
} else if (entry.text) {
orderedParts.push({
type: "reasoning",
text: entry.isError
? `Run error: ${summarizeNotice(entry.text)}`
: summarizeNotice(entry.text),
});
}
continue;
}
}
flushPendingDiff();
const mergedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
for (const part of orderedParts) {
if (part.type === "tool-call") {
mergedParts.push(part);
continue;
}
const previous = mergedParts.at(-1);
if (previous && previous.type === part.type && previous.parentId === part.parentId) {
mergedParts[mergedParts.length - 1] = {
...previous,
text: mergePartText(previous, part),
};
continue;
}
mergedParts.push(part);
}
return {
parts: mergedParts,
notices,
segments: computeSegmentTimings(entries),
};
}
function normalizeLiveRuns(
liveRuns: readonly LiveRunForIssue[],
activeRun: ActiveRunForIssue | null | undefined,
issueId?: string,
) {
const deduped = new Map<string, LiveRunForIssue>();
for (const run of liveRuns) {
deduped.set(run.id, run);
}
if (activeRun) {
deduped.set(activeRun.id, {
id: activeRun.id,
status: activeRun.status,
invocationSource: activeRun.invocationSource,
triggerDetail: activeRun.triggerDetail,
startedAt: activeRun.startedAt ? toDate(activeRun.startedAt).toISOString() : null,
finishedAt: activeRun.finishedAt ? toDate(activeRun.finishedAt).toISOString() : null,
createdAt: toDate(activeRun.createdAt).toISOString(),
agentId: activeRun.agentId,
agentName: activeRun.agentName,
adapterType: activeRun.adapterType,
issueId,
});
}
return [...deduped.values()].sort((a, b) => toTimestamp(a.createdAt) - toTimestamp(b.createdAt));
}
function createLiveRunMessage(args: {
run: LiveRunForIssue;
transcript: readonly IssueChatTranscriptEntry[];
hasOutput: boolean;
}) {
const { run, transcript, hasOutput } = args;
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const waitingText =
run.status === "queued"
? "Queued..."
: hasOutput
? ""
: "Working...";
const content = parts.length > 0
? parts
: waitingText
? [{ type: "text", text: waitingText } satisfies TextMessagePart]
: [];
const message: ThreadAssistantMessage = {
id: `live-run:${run.id}`,
role: "assistant",
createdAt: toDate(run.startedAt ?? run.createdAt),
content,
status: { type: "running" },
metadata: createAssistantMetadata({
kind: "live-run",
runId: run.id,
runAgentId: run.agentId,
runAgentName: run.agentName,
runStatus: run.status,
adapterType: run.adapterType,
notices,
waitingText,
chainOfThoughtLabel: runDurationLabel(run),
chainOfThoughtSegments: segments,
}),
};
return message;
}
export function buildIssueChatMessages(args: {
comments: readonly IssueChatComment[];
timelineEvents: readonly IssueTimelineEvent[];
linkedRuns: readonly IssueChatLinkedRun[];
liveRuns: readonly LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
hasOutputForRun?: (runId: string) => boolean;
includeSucceededRunsWithoutOutput?: boolean;
issueId?: string;
companyId?: string | null;
projectId?: string | null;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
}) {
const {
comments,
timelineEvents,
linkedRuns,
liveRuns,
activeRun,
transcriptsByRunId,
hasOutputForRun,
includeSucceededRunsWithoutOutput = false,
issueId,
companyId,
projectId,
agentMap,
currentUserId,
} = args;
const orderedMessages: MessageWithOrder[] = [];
for (const comment of sortByCreated(comments)) {
orderedMessages.push({
createdAtMs: toTimestamp(comment.createdAt),
order: 1,
message: createCommentMessage({ comment, agentMap, currentUserId, companyId, projectId }),
});
}
for (const event of sortByCreated(timelineEvents)) {
orderedMessages.push({
createdAtMs: toTimestamp(event.createdAt),
order: 0,
message: createTimelineEventMessage({ event, agentMap, currentUserId }),
});
}
for (const run of [...linkedRuns].sort((a, b) => toTimestamp(runTimestamp(a)) - toTimestamp(runTimestamp(b)))) {
const transcript = transcriptsByRunId?.get(run.runId) ?? [];
const hasRunOutput = transcript.length > 0 || (hasOutputForRun?.(run.runId) ?? false);
if (hasRunOutput) {
orderedMessages.push({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
order: 2,
message: createHistoricalTranscriptMessage({
run,
transcript,
hasOutput: hasRunOutput,
agentMap,
}),
});
continue;
}
if (run.status === "succeeded" && !includeSucceededRunsWithoutOutput) continue;
orderedMessages.push({
createdAtMs: toTimestamp(runTimestamp(run)),
order: 2,
message: createHistoricalRunMessage(run, agentMap),
});
}
for (const run of normalizeLiveRuns(liveRuns, activeRun, issueId)) {
orderedMessages.push({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
order: 3,
message: createLiveRunMessage({
run,
transcript: transcriptsByRunId?.get(run.id) ?? [],
hasOutput: hasOutputForRun?.(run.id) ?? false,
}),
});
}
return orderedMessages
.sort((a, b) => {
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
if (a.order !== b.order) return a.order - b.order;
return a.message.id.localeCompare(b.message.id);
})
.map((entry) => entry.message);
}

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { describeToolInput, summarizeToolInput } from "./transcriptPresentation";
describe("summarizeToolInput", () => {
it("prefers human descriptions over raw commands when both exist", () => {
expect(
summarizeToolInput("command_execution", {
description: "Inspect the issue chat thread layout classes",
command: "zsh -lc 'sed -n \"1,220p\" ui/src/components/IssueChatThread.tsx'",
}),
).toBe("Inspect the issue chat thread layout classes");
});
});
describe("describeToolInput", () => {
it("keeps command tools description-first in the detail view", () => {
expect(
describeToolInput("command_execution", {
description: "Inspect the issue chat thread layout classes",
command: "zsh -lc 'sed -n \"1,220p\" ui/src/components/IssueChatThread.tsx'",
cwd: "/workspace/paperclip",
}),
).toEqual([
{ label: "Intent", value: "Inspect the issue chat thread layout classes", tone: "default" },
{ label: "Directory", value: "/workspace/paperclip", tone: "default" },
]);
});
it("surfaces concise structured details for file tools", () => {
expect(
describeToolInput("read_file", {
path: "ui/src/lib/issue-chat-messages.ts",
}),
).toEqual([
{ label: "Path", value: "ui/src/lib/issue-chat-messages.ts", tone: "default" },
]);
});
});

View file

@ -0,0 +1,281 @@
type TranscriptDensity = "comfortable" | "compact";
type TranscriptActivity = {
activityId?: string;
name: string;
status: "running" | "completed";
};
export interface ToolInputDetail {
label: string;
value: string;
tone?: "default" | "code";
}
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 compactWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function truncate(value: string, max: number): string {
return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}...` : value;
}
function humanizeLabel(value: string): string {
return value
.replace(/[_-]+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
function stripWrappedShell(command: string): string {
const trimmed = compactWhitespace(command);
const shellWrapped = trimmed.match(/^(?:(?:\/bin\/)?(?:zsh|bash|sh)|cmd(?:\.exe)?(?:\s+\/d)?(?:\s+\/s)?(?:\s+\/c)?)\s+(?:-lc|\/c)\s+(.+)$/i);
const inner = shellWrapped?.[1] ?? trimmed;
const quoted = inner.match(/^(['"])([\s\S]*)\1$/);
return compactWhitespace(quoted?.[2] ?? inner);
}
function formatUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function summarizeRecord(record: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return truncate(compactWhitespace(value), 120);
}
}
return null;
}
function parseStructuredToolResult(result: string | undefined) {
if (!result) return null;
const lines = result.split(/\r?\n/);
const metadata = new Map<string, string>();
let bodyStartIndex = lines.findIndex((line) => line.trim() === "");
if (bodyStartIndex === -1) bodyStartIndex = lines.length;
for (let index = 0; index < bodyStartIndex; index += 1) {
const match = lines[index]?.match(/^([a-z_]+):\s*(.+)$/i);
if (match) {
metadata.set(match[1].toLowerCase(), compactWhitespace(match[2]));
}
}
const body = lines.slice(Math.min(bodyStartIndex + 1, lines.length))
.map((line) => compactWhitespace(line))
.filter(Boolean)
.join("\n");
return {
command: metadata.get("command") ?? null,
status: metadata.get("status") ?? null,
exitCode: metadata.get("exit_code") ?? null,
body,
};
}
export function formatToolPayload(value: unknown): string {
if (typeof value === "string") {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
return formatUnknown(value);
}
export function parseToolPayload(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
export function isCommandTool(name: string, input: unknown): boolean {
if (name === "command_execution" || name === "shell" || name === "shellToolCall" || name === "bash") {
return true;
}
if (typeof input === "string") {
return /\b(?:bash|zsh|sh|cmd|powershell)\b/i.test(input);
}
const record = asRecord(input);
return Boolean(record && (typeof record.command === "string" || typeof record.cmd === "string"));
}
export function displayToolName(name: string, input: unknown): string {
if (isCommandTool(name, input)) return "Executing command";
return humanizeLabel(name);
}
export function summarizeToolInput(
name: string,
input: unknown,
density: TranscriptDensity = "comfortable",
): string {
const compactMax = density === "compact" ? 72 : 120;
if (typeof input === "string") {
const normalized = isCommandTool(name, input) ? stripWrappedShell(input) : compactWhitespace(input);
return truncate(normalized, compactMax);
}
const record = asRecord(input);
if (!record) {
const serialized = compactWhitespace(formatUnknown(input));
return serialized ? truncate(serialized, compactMax) : `Inspect ${name} input`;
}
const command = typeof record.command === "string"
? record.command
: typeof record.cmd === "string"
? record.cmd
: null;
const humanDescription =
summarizeRecord(record, ["description", "summary", "reason", "goal", "intent", "action", "task"])
?? null;
if (humanDescription) {
return truncate(humanDescription, compactMax);
}
if (command && isCommandTool(name, record)) {
return truncate(stripWrappedShell(command), compactMax);
}
const direct =
summarizeRecord(record, ["path", "filePath", "file_path", "query", "url", "prompt", "message"])
?? summarizeRecord(record, ["pattern", "name", "title", "target", "tool", "command", "cmd"])
?? null;
if (direct) return truncate(direct, compactMax);
if (Array.isArray(record.paths) && record.paths.length > 0) {
const first = record.paths.find((value): value is string => typeof value === "string" && value.trim().length > 0);
if (first) {
return truncate(`${record.paths.length} paths, starting with ${first}`, compactMax);
}
}
const keys = Object.keys(record);
if (keys.length === 0) return `No ${name} input`;
if (keys.length === 1) return truncate(`${keys[0]} payload`, compactMax);
return truncate(`${keys.length} fields: ${keys.slice(0, 3).join(", ")}`, compactMax);
}
function readToolDetailValue(value: unknown, max = 200): string | null {
if (typeof value === "string") {
const normalized = compactWhitespace(value);
return normalized ? truncate(normalized, max) : null;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return null;
}
export function describeToolInput(name: string, input: unknown): ToolInputDetail[] {
if (typeof input === "string") {
const summary = compactWhitespace(isCommandTool(name, input) ? stripWrappedShell(input) : input);
return summary ? [{ label: isCommandTool(name, input) ? "Command" : "Input", value: truncate(summary, 200), tone: "code" }] : [];
}
const record = asRecord(input);
if (!record) return [];
const details: ToolInputDetail[] = [];
const seen = new Set<string>();
const pushDetail = (label: string, value: string | null, tone: ToolInputDetail["tone"] = "default") => {
if (!value) return;
const key = `${label}:${value}`;
if (seen.has(key)) return;
seen.add(key);
details.push({ label, value, tone });
};
pushDetail(
"Intent",
summarizeRecord(record, ["description", "summary", "reason", "goal", "intent", "action", "task"]) ?? null,
);
pushDetail("Path", readToolDetailValue(record.path) ?? readToolDetailValue(record.filePath) ?? readToolDetailValue(record.file_path));
pushDetail("Directory", readToolDetailValue(record.cwd));
pushDetail("Query", readToolDetailValue(record.query));
pushDetail("Target", readToolDetailValue(record.url) ?? readToolDetailValue(record.target));
pushDetail("Prompt", readToolDetailValue(record.prompt) ?? readToolDetailValue(record.message));
pushDetail("Pattern", readToolDetailValue(record.pattern));
pushDetail("Name", readToolDetailValue(record.name) ?? readToolDetailValue(record.title));
if (Array.isArray(record.paths) && record.paths.length > 0) {
const paths = record.paths
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.slice(0, 3)
.join(", ");
if (paths) {
const suffix = record.paths.length > 3 ? `, +${record.paths.length - 3} more` : "";
pushDetail("Paths", `${paths}${suffix}`);
}
}
const command = typeof record.command === "string"
? record.command
: typeof record.cmd === "string"
? record.cmd
: null;
if (command && isCommandTool(name, record) && !details.some((detail) => detail.label === "Intent")) {
pushDetail("Command", truncate(stripWrappedShell(command), 200), "code");
}
return details;
}
export function summarizeToolResult(
result: string | undefined,
isError: boolean | undefined,
density: TranscriptDensity = "comfortable",
): string {
if (!result) return isError ? "Tool failed" : "Waiting for result";
const structured = parseStructuredToolResult(result);
if (structured) {
if (structured.body) {
return truncate(structured.body.split("\n")[0] ?? structured.body, density === "compact" ? 84 : 140);
}
if (structured.status === "completed") return "Completed";
if (structured.status === "failed" || structured.status === "error") {
return structured.exitCode ? `Failed with exit code ${structured.exitCode}` : "Failed";
}
}
const lines = result
.split(/\r?\n/)
.map((line) => compactWhitespace(line))
.filter(Boolean);
const firstLine = lines[0] ?? result;
return truncate(firstLine, density === "compact" ? 84 : 140);
}
export function parseSystemActivity(text: string): TranscriptActivity | null {
const match = text.match(/^item (started|completed):\s*([a-z0-9_-]+)(?:\s+\(id=([^)]+)\))?$/i);
if (!match) return null;
return {
status: match[1].toLowerCase() === "started" ? "running" : "completed",
name: humanizeLabel(match[2] ?? "Activity"),
activityId: match[3] || undefined,
};
}
export function shouldHideNiceModeStderr(text: string): boolean {
const normalized = compactWhitespace(text).toLowerCase();
return normalized.startsWith("[paperclip] skipping saved session resume");
}
export function summarizeNotice(text: string, max = 160): string {
return truncate(compactWhitespace(text), max);
}

View file

@ -29,6 +29,13 @@ export function formatDateTime(date: Date | string): string {
});
}
export function formatShortDate(date: Date | string): string {
return new Date(date).toLocaleString("en-US", {
month: "short",
day: "numeric",
});
}
export function relativeTime(date: Date | string): string {
const now = Date.now();
const then = new Date(date).getTime();

View file

@ -0,0 +1,328 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { IssueChatThread } from "../components/IssueChatThread";
import {
issueChatUxAgentMap,
issueChatUxFeedbackVotes,
issueChatUxLinkedRuns,
issueChatUxLiveComments,
issueChatUxLiveEvents,
issueChatUxLiveRuns,
issueChatUxMentions,
issueChatUxReassignOptions,
issueChatUxReviewComments,
issueChatUxReviewEvents,
issueChatUxTranscriptsByRunId,
} from "../fixtures/issueChatUxFixtures";
import { cn } from "../lib/utils";
import { Bot, Brain, FlaskConical, MessagesSquare, Route, Sparkles, WandSparkles } from "lucide-react";
const noop = async () => {};
const highlights = [
"Running assistant replies with streamed text, reasoning, tool cards, and background status notes",
"Historical issue events and linked runs rendered inline with the chat timeline",
"Queued user messages, settled assistant comments, and feedback controls",
"Empty and disabled-composer states without relying on live backend data",
];
function LabSection({
id,
eyebrow,
title,
description,
accentClassName,
children,
}: {
id?: string;
eyebrow: string;
title: string;
description: string;
accentClassName?: string;
children: ReactNode;
}) {
return (
<section
id={id}
className={cn(
"rounded-[28px] border border-border/70 bg-background/80 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
accentClassName,
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
{eyebrow}
</div>
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
</section>
);
}
const DEMO_REASONING_LINES = [
"Analyzing the user's request about the animation smoothness...",
"The current implementation unmounts the old span instantly, causing a flash...",
"Looking at the CSS keyframes for cot-line-slide-up...",
"We need a paired exit animation so the old line slides out while the new one slides in...",
"Implementing a two-span ticker: exiting line goes up and out, entering line comes up from below...",
"Testing the 280ms cubic-bezier transition timing...",
];
function RotatingReasoningDemo({ intervalMs = 2200 }: { intervalMs?: number }) {
const [index, setIndex] = useState(0);
const prevRef = useRef(DEMO_REASONING_LINES[0]);
const [ticker, setTicker] = useState<{
key: number;
current: string;
exiting: string | null;
}>({ key: 0, current: DEMO_REASONING_LINES[0], exiting: null });
useEffect(() => {
const timer = setInterval(() => {
setIndex((i) => (i + 1) % DEMO_REASONING_LINES.length);
}, intervalMs);
return () => clearInterval(timer);
}, [intervalMs]);
const currentLine = DEMO_REASONING_LINES[index];
useEffect(() => {
if (currentLine !== prevRef.current) {
const prev = prevRef.current;
prevRef.current = currentLine;
setTicker((t) => ({ key: t.key + 1, current: currentLine, exiting: prev }));
}
}, [currentLine]);
return (
<div className="flex gap-2 px-1">
<div className="flex flex-col items-center pt-0.5">
<Brain className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
</div>
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
{ticker.exiting !== null && (
<span
key={`out-${ticker.key}`}
className="cot-line-exit absolute inset-x-0 truncate text-[13px] italic leading-5 text-muted-foreground/70"
onAnimationEnd={() => setTicker((t) => ({ ...t, exiting: null }))}
>
{ticker.exiting}
</span>
)}
<span
key={`in-${ticker.key}`}
className={cn(
"absolute inset-x-0 truncate text-[13px] italic leading-5 text-muted-foreground/70",
ticker.key > 0 && "cot-line-enter",
)}
>
{ticker.current}
</span>
</div>
</div>
);
}
export function IssueChatUxLab() {
const [showComposer, setShowComposer] = useState(true);
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="p-6 sm:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-500/25 bg-cyan-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-cyan-700 dark:text-cyan-300">
<FlaskConical className="h-3.5 w-3.5" />
Chat UX Lab
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight">Issue chat review surface</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
This page exercises the real assistant-ui issue chat with fixture-backed messages. Use it to review
spacing, chronology, running states, tool rendering, activity rows, queueing, and composer behavior
without needing a live issue in progress.
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
/tests/ux/chat
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
assistant-ui thread
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
fixture-backed live run
</Badge>
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" className="rounded-full" onClick={() => setShowComposer((value) => !value)}>
{showComposer ? "Hide composer in primary preview" : "Show composer in primary preview"}
</Button>
<a
href="#live-execution"
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/80 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<Route className="h-3.5 w-3.5" />
Jump to live execution preview
</a>
</div>
</div>
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
<div className="mb-4 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
<WandSparkles className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
Covered states
</div>
<div className="space-y-3">
{highlights.map((highlight) => (
<div
key={highlight}
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
>
{highlight}
</div>
))}
</div>
</aside>
</div>
</div>
<LabSection
id="rotating-text"
eyebrow="Animation demo"
title="Rotating reasoning text"
description="Isolated ticker that cycles sample reasoning lines on a timer. The outgoing line slides up and fades out while the incoming line slides up from below. Runs in a loop so you can tune timing and easing without needing a live stream."
accentClassName="bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]"
>
<div className="space-y-4">
<div className="rounded-xl border border-border/60 bg-accent/10 p-4">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Default interval (2.2s)
</div>
<RotatingReasoningDemo />
</div>
<div className="rounded-xl border border-border/60 bg-accent/10 p-4">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Fast interval (1s) stress test
</div>
<RotatingReasoningDemo intervalMs={1000} />
</div>
</div>
</LabSection>
<LabSection
id="live-execution"
eyebrow="Primary preview"
title="Live execution thread"
description="Shows the fully active state: timeline events, historical run marker, a running assistant reply with reasoning and tools, and a queued follow-up from the user."
accentClassName="bg-[linear-gradient(180deg,rgba(6,182,212,0.05),transparent_28%),var(--background)]"
>
<IssueChatThread
comments={issueChatUxLiveComments}
linkedRuns={issueChatUxLinkedRuns.slice(0, 1)}
timelineEvents={issueChatUxLiveEvents}
liveRuns={issueChatUxLiveRuns}
issueStatus="todo"
agentMap={issueChatUxAgentMap}
currentUserId="user-1"
onAdd={noop}
onVote={noop}
onCancelRun={noop}
onInterruptQueued={noop}
draftKey="issue-chat-ux-lab-primary"
enableReassign
reassignOptions={issueChatUxReassignOptions}
currentAssigneeValue="agent:agent-1"
suggestedAssigneeValue="agent:agent-2"
mentions={issueChatUxMentions}
showComposer={showComposer}
enableLiveTranscriptPolling={false}
transcriptsByRunId={issueChatUxTranscriptsByRunId}
hasOutputForRun={(runId) => issueChatUxTranscriptsByRunId.has(runId)}
/>
</LabSection>
<div className="grid gap-6 xl:grid-cols-2">
<LabSection
eyebrow="Settled review"
title="Durable comments and feedback"
description="Shows the post-run state: assistant comment feedback controls, historical run context, and timeline reassignment without any active stream."
accentClassName="bg-[linear-gradient(180deg,rgba(168,85,247,0.05),transparent_26%),var(--background)]"
>
<IssueChatThread
comments={issueChatUxReviewComments}
linkedRuns={issueChatUxLinkedRuns.slice(1)}
timelineEvents={issueChatUxReviewEvents}
feedbackVotes={issueChatUxFeedbackVotes}
feedbackTermsUrl="/feedback-terms"
issueStatus="in_review"
agentMap={issueChatUxAgentMap}
currentUserId="user-1"
onAdd={noop}
onVote={noop}
draftKey="issue-chat-ux-lab-review"
showComposer={false}
enableLiveTranscriptPolling={false}
/>
</LabSection>
<div className="space-y-6">
<LabSection
eyebrow="Empty thread"
title="Empty state and disabled composer"
description="Keeps the message area visible even when there is no thread yet, and replaces the composer with an explicit warning when replies are blocked."
accentClassName="bg-[linear-gradient(180deg,rgba(245,158,11,0.08),transparent_26%),var(--background)]"
>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
issueStatus="done"
agentMap={issueChatUxAgentMap}
currentUserId="user-1"
onAdd={noop}
composerDisabledReason="This workspace is closed, so new chat replies are disabled until the issue is reopened."
draftKey="issue-chat-ux-lab-empty"
enableLiveTranscriptPolling={false}
/>
</LabSection>
<Card className="gap-4 border-border/70 bg-background/85 py-0">
<CardHeader className="px-5 pt-5 pb-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<MessagesSquare className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
Review checklist
</div>
<CardTitle className="text-lg">What to evaluate on this page</CardTitle>
<CardDescription>
This route should be the fastest way to inspect the chat system before or after tweaks.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5 pt-0 text-sm text-muted-foreground">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 flex items-center gap-2 font-medium text-foreground">
<Bot className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
Message hierarchy
</div>
Check that user, assistant, and system rows scan differently without feeling like separate products.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 flex items-center gap-2 font-medium text-foreground">
<Sparkles className="h-4 w-4 text-cyan-700 dark:text-cyan-300" />
Stream polish
</div>
Watch the live preview for reasoning density, tool expansion behavior, and queued follow-up readability.
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View file

@ -40,11 +40,10 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread";
import { IssueChatThread } from "../components/IssueChatThread";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import { LiveRunWidget } from "../components/LiveRunWidget";
import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom";
@ -300,7 +299,7 @@ export function IssueDetail() {
const [moreOpen, setMoreOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [detailTab, setDetailTab] = useState("comments");
const [detailTab, setDetailTab] = useState("chat");
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
approvalId: string;
action: "approve" | "reject";
@ -610,15 +609,6 @@ export function IssueDetail() {
});
}, [activity, threadComments, linkedRuns, runningIssueRun]);
const queuedComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState === "queued"),
[commentsWithRunMeta],
);
const timelineComments = useMemo(
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
[commentsWithRunMeta],
);
const timelineEvents = useMemo(
() => extractIssueTimelineEvents(activity),
[activity],
@ -1713,9 +1703,9 @@ export function IssueDetail() {
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="comments" className="gap-1.5">
<TabsTrigger value="chat" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Comments
Chat
</TabsTrigger>
<TabsTrigger value="activity" className="gap-1.5">
<ActivityIcon className="h-3.5 w-3.5" />
@ -1728,25 +1718,18 @@ export function IssueDetail() {
))}
</TabsList>
<TabsContent value="comments">
<CommentThread
comments={timelineComments}
queuedComments={queuedComments}
linkedApprovals={linkedApprovals}
<TabsContent value="chat">
<IssueChatThread
comments={commentsWithRunMeta}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
liveRuns={liveRuns}
activeRun={activeRun}
companyId={issue.companyId}
projectId={issue.projectId}
onApproveApproval={async (approvalId) => {
await approvalDecision.mutateAsync({ approvalId, action: "approve" });
}}
onRejectApproval={async (approvalId) => {
await approvalDecision.mutateAsync({ approvalId, action: "reject" });
}}
pendingApprovalAction={pendingApprovalAction}
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
@ -1756,10 +1739,6 @@ export function IssueDetail() {
currentAssigneeValue={actualAssigneeValue}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentionOptions}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
composerDisabledReason={commentComposerDisabledReason}
onVote={async (commentId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
@ -1785,7 +1764,15 @@ export function IssueDetail() {
onAttachImage={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
liveRunSlot={<LiveRunWidget issueId={issueId!} companyId={issue.companyId} />}
onInterruptQueued={async (runId) => {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
onCancelRun={runningIssueRun
? async () => {
await interruptQueuedComment.mutateAsync(runningIssueRun.id);
}
: undefined}
/>
</TabsContent>