mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
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:
commit
667d5a7384
25 changed files with 4810 additions and 63 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
262
ui/src/components/IssueChatThread.test.tsx
Normal file
262
ui/src/components/IssueChatThread.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
1814
ui/src/components/IssueChatThread.tsx
Normal file
1814
ui/src/components/IssueChatThread.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
64
ui/src/components/RunChatSurface.tsx
Normal file
64
ui/src/components/RunChatSurface.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
337
ui/src/fixtures/issueChatUxFixtures.ts
Normal file
337
ui/src/fixtures/issueChatUxFixtures.ts
Normal 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"),
|
||||
},
|
||||
];
|
||||
68
ui/src/hooks/usePaperclipIssueRuntime.ts
Normal file
68
ui/src/hooks/usePaperclipIssueRuntime.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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-");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
370
ui/src/lib/issue-chat-messages.test.ts
Normal file
370
ui/src/lib/issue-chat-messages.test.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
727
ui/src/lib/issue-chat-messages.ts
Normal file
727
ui/src/lib/issue-chat-messages.ts
Normal 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);
|
||||
}
|
||||
38
ui/src/lib/transcriptPresentation.test.ts
Normal file
38
ui/src/lib/transcriptPresentation.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
281
ui/src/lib/transcriptPresentation.ts
Normal file
281
ui/src/lib/transcriptPresentation.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
328
ui/src/pages/IssueChatUxLab.tsx
Normal file
328
ui/src/pages/IssueChatUxLab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue