fix(ui): polish issue detail timelines and attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
36376968af
commit
bd6d07d0b4
25 changed files with 2020 additions and 82 deletions
|
|
@ -175,6 +175,8 @@ Seed modes:
|
|||
|
||||
After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance.
|
||||
|
||||
Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development.
|
||||
|
||||
That repo-local env also sets:
|
||||
|
||||
- `PAPERCLIP_IN_WORKTREE=true`
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ Runs local `claude` CLI directly.
|
|||
"cwd": "/absolute/or/relative/path",
|
||||
"promptTemplate": "You are agent {{agent.id}} ...",
|
||||
"model": "optional-model-id",
|
||||
"maxTurnsPerRun": 300,
|
||||
"maxTurnsPerRun": 1000,
|
||||
"dangerouslySkipPermissions": true,
|
||||
"env": {"KEY": "VALUE"},
|
||||
"extraArgs": [],
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports
|
|||
| `env` | object | No | Environment variables (supports secret refs) |
|
||||
| `timeoutSec` | number | No | Process timeout (0 = no timeout) |
|
||||
| `graceSec` | number | No | Grace period before force-kill |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) |
|
||||
| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `1000`) |
|
||||
| `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) |
|
||||
|
||||
## Prompt Templates
|
||||
|
|
|
|||
179
packages/db/src/backup-lib.test.ts
Normal file
179
packages/db/src/backup-lib.test.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { createBufferedTextFileWriter, runDatabaseBackup, runDatabaseRestore } from "./backup-lib.js";
|
||||
import { ensurePostgresDatabase } from "./client.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void> | void> = [];
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function createTempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
cleanups.push(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function createTempDatabase(): Promise<string> {
|
||||
const db = await startEmbeddedPostgresTestDatabase("paperclip-db-backup-");
|
||||
cleanups.push(db.cleanup);
|
||||
return db.connectionString;
|
||||
}
|
||||
|
||||
async function createSiblingDatabase(connectionString: string, databaseName: string): Promise<string> {
|
||||
const adminUrl = new URL(connectionString);
|
||||
adminUrl.pathname = "/postgres";
|
||||
await ensurePostgresDatabase(adminUrl.toString(), databaseName);
|
||||
const targetUrl = new URL(connectionString);
|
||||
targetUrl.pathname = `/${databaseName}`;
|
||||
return targetUrl.toString();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
const cleanup = cleanups.pop();
|
||||
await cleanup?.();
|
||||
}
|
||||
});
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres backup tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createBufferedTextFileWriter", () => {
|
||||
it("preserves line boundaries across buffered flushes", async () => {
|
||||
const tempDir = createTempDir("paperclip-buffered-writer-");
|
||||
const outputPath = path.join(tempDir, "backup.sql");
|
||||
const writer = createBufferedTextFileWriter(outputPath, 16);
|
||||
const lines = [
|
||||
"-- header",
|
||||
"BEGIN;",
|
||||
"",
|
||||
"INSERT INTO test VALUES (1);",
|
||||
"-- footer",
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
writer.emit(line);
|
||||
}
|
||||
|
||||
await writer.close();
|
||||
|
||||
expect(fs.readFileSync(outputPath, "utf8")).toBe(lines.join("\n"));
|
||||
});
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("runDatabaseBackup", () => {
|
||||
it(
|
||||
"backs up and restores large table payloads without materializing one giant string",
|
||||
async () => {
|
||||
const sourceConnectionString = await createTempDatabase();
|
||||
const restoreConnectionString = await createSiblingDatabase(
|
||||
sourceConnectionString,
|
||||
"paperclip_restore_target",
|
||||
);
|
||||
const backupDir = createTempDir("paperclip-db-backup-output-");
|
||||
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
|
||||
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
|
||||
|
||||
try {
|
||||
await sourceSql.unsafe(`
|
||||
CREATE TYPE "public"."backup_test_state" AS ENUM ('pending', 'done');
|
||||
`);
|
||||
await sourceSql.unsafe(`
|
||||
CREATE TABLE "public"."backup_test_records" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"title" text NOT NULL,
|
||||
"payload" text NOT NULL,
|
||||
"state" "public"."backup_test_state" NOT NULL,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`);
|
||||
|
||||
const payload = "x".repeat(8192);
|
||||
for (let index = 0; index < 160; index += 1) {
|
||||
const createdAt = new Date(Date.UTC(2026, 0, 1, 0, 0, index));
|
||||
await sourceSql`
|
||||
INSERT INTO "public"."backup_test_records" (
|
||||
"title",
|
||||
"payload",
|
||||
"state",
|
||||
"metadata",
|
||||
"created_at"
|
||||
)
|
||||
VALUES (
|
||||
${`row-${index}`},
|
||||
${payload},
|
||||
${index % 2 === 0 ? "pending" : "done"}::"public"."backup_test_state",
|
||||
${JSON.stringify({ index, even: index % 2 === 0 })}::jsonb,
|
||||
${createdAt}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const result = await runDatabaseBackup({
|
||||
connectionString: sourceConnectionString,
|
||||
backupDir,
|
||||
retentionDays: 7,
|
||||
filenamePrefix: "paperclip-test",
|
||||
});
|
||||
|
||||
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/);
|
||||
expect(result.sizeBytes).toBeGreaterThan(1024 * 1024);
|
||||
expect(fs.existsSync(result.backupFile)).toBe(true);
|
||||
|
||||
await runDatabaseRestore({
|
||||
connectionString: restoreConnectionString,
|
||||
backupFile: result.backupFile,
|
||||
});
|
||||
|
||||
const counts = await restoreSql.unsafe<{ count: number }[]>(`
|
||||
SELECT count(*)::int AS count
|
||||
FROM "public"."backup_test_records"
|
||||
`);
|
||||
expect(counts[0]?.count).toBe(160);
|
||||
|
||||
const sampleRows = await restoreSql.unsafe<{
|
||||
title: string;
|
||||
payload: string;
|
||||
state: string;
|
||||
metadata: { index: number; even: boolean };
|
||||
}[]>(`
|
||||
SELECT "title", "payload", "state"::text AS "state", "metadata"
|
||||
FROM "public"."backup_test_records"
|
||||
WHERE "title" IN ('row-0', 'row-159')
|
||||
ORDER BY "title"
|
||||
`);
|
||||
expect(sampleRows).toEqual([
|
||||
{
|
||||
title: "row-0",
|
||||
payload,
|
||||
state: "pending",
|
||||
metadata: { index: 0, even: true },
|
||||
},
|
||||
{
|
||||
title: "row-159",
|
||||
payload,
|
||||
state: "done",
|
||||
metadata: { index: 159, even: false },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await sourceSql.end();
|
||||
await restoreSql.end();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import postgres from "postgres";
|
||||
|
||||
|
|
@ -47,6 +47,7 @@ type TableDefinition = {
|
|||
|
||||
const DRIZZLE_SCHEMA = "drizzle";
|
||||
const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations";
|
||||
const DEFAULT_BACKUP_WRITE_BUFFER_BYTES = 1024 * 1024;
|
||||
|
||||
const STATEMENT_BREAKPOINT = "-- paperclip statement breakpoint 69f6f3f1-42fd-46a6-bf17-d1d85f8f3900";
|
||||
|
||||
|
|
@ -141,6 +142,98 @@ function tableKey(schemaName: string, tableName: string): string {
|
|||
return `${schemaName}.${tableName}`;
|
||||
}
|
||||
|
||||
export function createBufferedTextFileWriter(filePath: string, maxBufferedBytes = DEFAULT_BACKUP_WRITE_BUFFER_BYTES) {
|
||||
const stream = createWriteStream(filePath, { encoding: "utf8" });
|
||||
const flushThreshold = Math.max(1, Math.trunc(maxBufferedBytes));
|
||||
let bufferedLines: string[] = [];
|
||||
let bufferedBytes = 0;
|
||||
let firstChunk = true;
|
||||
let closed = false;
|
||||
let streamError: Error | null = null;
|
||||
let pendingWrite = Promise.resolve();
|
||||
|
||||
stream.on("error", (error) => {
|
||||
streamError = error;
|
||||
});
|
||||
|
||||
const writeChunk = async (chunk: string): Promise<void> => {
|
||||
if (streamError) throw streamError;
|
||||
const canContinue = stream.write(chunk);
|
||||
if (!canContinue) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const handleDrain = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const cleanup = () => {
|
||||
stream.off("drain", handleDrain);
|
||||
stream.off("error", handleError);
|
||||
};
|
||||
stream.once("drain", handleDrain);
|
||||
stream.once("error", handleError);
|
||||
});
|
||||
}
|
||||
if (streamError) throw streamError;
|
||||
};
|
||||
|
||||
const flushBufferedLines = () => {
|
||||
if (bufferedLines.length === 0) return;
|
||||
const linesToWrite = bufferedLines;
|
||||
bufferedLines = [];
|
||||
bufferedBytes = 0;
|
||||
const chunkBody = linesToWrite.join("\n");
|
||||
const chunk = firstChunk ? chunkBody : `\n${chunkBody}`;
|
||||
firstChunk = false;
|
||||
pendingWrite = pendingWrite.then(() => writeChunk(chunk));
|
||||
};
|
||||
|
||||
return {
|
||||
emit(line: string) {
|
||||
if (closed) {
|
||||
throw new Error(`Cannot write to closed backup file: ${filePath}`);
|
||||
}
|
||||
if (streamError) throw streamError;
|
||||
bufferedLines.push(line);
|
||||
bufferedBytes += Buffer.byteLength(line, "utf8") + 1;
|
||||
if (bufferedBytes >= flushThreshold) {
|
||||
flushBufferedLines();
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
flushBufferedLines();
|
||||
await pendingWrite;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (streamError) {
|
||||
reject(streamError);
|
||||
return;
|
||||
}
|
||||
stream.end((error?: Error | null) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
if (streamError) throw streamError;
|
||||
},
|
||||
async abort() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
bufferedLines = [];
|
||||
bufferedBytes = 0;
|
||||
stream.destroy();
|
||||
await pendingWrite.catch(() => {});
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise<RunDatabaseBackupResult> {
|
||||
const filenamePrefix = opts.filenamePrefix ?? "paperclip";
|
||||
const retentionDays = Math.max(1, Math.trunc(opts.retentionDays));
|
||||
|
|
@ -149,12 +242,14 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
const excludedTableNames = normalizeTableNameSet(opts.excludeTables);
|
||||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
mkdirSync(opts.backupDir, { recursive: true });
|
||||
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
const writer = createBufferedTextFileWriter(backupFile);
|
||||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
|
||||
const lines: string[] = [];
|
||||
const emit = (line: string) => lines.push(line);
|
||||
const emit = (line: string) => writer.emit(line);
|
||||
const emitStatement = (statement: string) => {
|
||||
emit(statement);
|
||||
emit(STATEMENT_BREAKPOINT);
|
||||
|
|
@ -503,10 +598,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
emitStatement("COMMIT;");
|
||||
emit("");
|
||||
|
||||
// Write the backup file
|
||||
mkdirSync(opts.backupDir, { recursive: true });
|
||||
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
await writeFile(backupFile, lines.join("\n"), "utf8");
|
||||
await writer.close();
|
||||
|
||||
const sizeBytes = statSync(backupFile).size;
|
||||
const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix);
|
||||
|
|
@ -516,6 +608,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
sizeBytes,
|
||||
prunedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
await writer.abort();
|
||||
throw error;
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FeedbackDataSharingPreference } from "./feedback.js";
|
|||
|
||||
export interface InstanceGeneralSettings {
|
||||
censorUsernameInLogs: boolean;
|
||||
keyboardShortcuts: boolean;
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
|
|||
|
||||
export const instanceGeneralSettingsSchema = z.object({
|
||||
censorUsernameInLogs: z.boolean().default(false),
|
||||
keyboardShortcuts: z.boolean().default(false),
|
||||
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,12 +19,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
|
|||
if (parsed.success) {
|
||||
return {
|
||||
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
|
||||
keyboardShortcuts: parsed.data.keyboardShortcuts ?? false,
|
||||
feedbackDataSharingPreference:
|
||||
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
};
|
||||
}
|
||||
return {
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ If you are asked to create or manage routines you MUST read:
|
|||
- **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only.
|
||||
- **Escalate** via `chainOfCommand` when stuck. Reassign to manager or create a task for them.
|
||||
- **Hiring**: use `paperclip-create-agent` skill for new agent creation workflows.
|
||||
- **Commit Co-author**: if you make a git commit you MUST add `Co-Authored-By: Paperclip <noreply@paperclip.ing>` to the end of each commit message
|
||||
- **Commit Co-author**: if you make a git commit you MUST add EXACTLY `Co-Authored-By: Paperclip <noreply@paperclip.ing>` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip <noreply@paperclip.ing>`
|
||||
|
||||
## Comment Style (Required)
|
||||
|
||||
|
|
@ -293,10 +293,10 @@ PATCH /api/agents/{agentId}/instructions-path
|
|||
| Import company skills | `POST /api/companies/:companyId/skills/import` |
|
||||
| Scan project workspaces for skills | `POST /api/companies/:companyId/skills/scan-projects` |
|
||||
| Sync agent desired skills | `POST /api/agents/:agentId/skills/sync` |
|
||||
| Preview CEO-safe company import | `POST /api/companies/:companyId/imports/preview` |
|
||||
| Apply CEO-safe company import | `POST /api/companies/:companyId/imports/apply` |
|
||||
| Preview company export | `POST /api/companies/:companyId/exports/preview` |
|
||||
| Build company export | `POST /api/companies/:companyId/exports` |
|
||||
| Preview CEO-safe company import | `POST /api/companies/:companyId/imports/preview` |
|
||||
| Apply CEO-safe company import | `POST /api/companies/:companyId/imports/apply` |
|
||||
| Preview company export | `POST /api/companies/:companyId/exports/preview` |
|
||||
| Build company export | `POST /api/companies/:companyId/exports` |
|
||||
| Dashboard | `GET /api/companies/:companyId/dashboard` |
|
||||
| Search issues | `GET /api/companies/:companyId/issues?q=search+term` |
|
||||
| Upload attachment (multipart, field=file) | `POST /api/companies/:companyId/issues/:issueId/attachments` |
|
||||
|
|
|
|||
|
|
@ -125,9 +125,9 @@ export function ClaudeLocalAdvancedFields({
|
|||
value={eff(
|
||||
"adapterConfig",
|
||||
"maxTurnsPerRun",
|
||||
Number(config.maxTurnsPerRun ?? 300),
|
||||
Number(config.maxTurnsPerRun ?? 1000),
|
||||
)}
|
||||
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 300)}
|
||||
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 1000)}
|
||||
immediate
|
||||
className={inputClass}
|
||||
/>
|
||||
|
|
|
|||
123
ui/src/components/CommentThread.test.tsx
Normal file
123
ui/src/components/CommentThread.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// @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 type { Agent } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CommentThread } from "./CommentThread";
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", () => ({
|
||||
MarkdownEditor: ({ value, onChange, placeholder }: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="Comment editor"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CommentThread", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders historical runs as timeline rows using the finished time", () => {
|
||||
const root = createRoot(container);
|
||||
const agent: Agent = {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CodexCoder",
|
||||
urlKey: "codexcoder",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: "code",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread
|
||||
comments={[]}
|
||||
linkedRuns={[{
|
||||
runId: "run-12345678abcd",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
createdAt: "2026-03-11T07:00:00.000Z",
|
||||
startedAt: "2026-03-11T08:00:00.000Z",
|
||||
finishedAt: "2026-03-11T10:00:00.000Z",
|
||||
}]}
|
||||
agentMap={new Map([["agent-1", agent]])}
|
||||
onAdd={async () => {}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const runRow = container.querySelector("#run-run-12345678abcd") as HTMLDivElement | null;
|
||||
expect(runRow).not.toBeNull();
|
||||
expect(runRow?.className).toContain("py-1.5");
|
||||
expect(runRow?.className).toContain("items-center");
|
||||
expect(runRow?.className).not.toContain("border");
|
||||
expect(container.textContent).toContain("CodexCoder");
|
||||
expect(container.textContent).toContain("succeeded");
|
||||
expect(container.textContent).toContain("2h ago");
|
||||
expect(container.textContent).not.toContain("4h ago");
|
||||
const runLink = container.querySelector('a[href="/agents/agent-1/runs/run-12345678abcd"]') as HTMLAnchorElement | null;
|
||||
expect(runLink?.textContent).toContain("run-1234");
|
||||
expect(runLink?.className).toContain("rounded-md");
|
||||
expect(runLink?.className).toContain("px-2");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,8 @@ import type {
|
|||
IssueComment,
|
||||
} from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, Copy, Paperclip } from "lucide-react";
|
||||
import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Identity } from "./Identity";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
|
|
@ -16,7 +17,10 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
|
|||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatDateTime } from "../lib/utils";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatDateTime } from "../lib/utils";
|
||||
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
|
||||
|
|
@ -35,6 +39,7 @@ interface LinkedRunItem {
|
|||
agentId: string;
|
||||
createdAt: Date | string;
|
||||
startedAt: Date | string | null;
|
||||
finishedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
interface CommentReassignment {
|
||||
|
|
@ -49,6 +54,7 @@ interface CommentThreadProps {
|
|||
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
||||
feedbackTermsUrl?: string | null;
|
||||
linkedRuns?: LinkedRunItem[];
|
||||
timelineEvents?: IssueTimelineEvent[];
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
onVote?: (
|
||||
|
|
@ -59,6 +65,7 @@ interface CommentThreadProps {
|
|||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
||||
issueStatus?: string;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
/** Callback to attach an image file to the parent issue (not inline in a comment). */
|
||||
onAttachImage?: (file: File) => Promise<void>;
|
||||
|
|
@ -118,6 +125,82 @@ function parseReassignment(target: string): CommentReassignment | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function humanizeValue(value: string | null): string {
|
||||
if (!value) return "None";
|
||||
return value.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function formatTimelineAssigneeLabel(
|
||||
assignee: IssueTimelineAssignee,
|
||||
agentMap?: Map<string, Agent>,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
if (assignee.agentId) {
|
||||
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
|
||||
}
|
||||
if (assignee.userId) {
|
||||
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
|
||||
}
|
||||
return "Unassigned";
|
||||
}
|
||||
|
||||
function formatTimelineActorName(
|
||||
actorType: IssueTimelineEvent["actorType"],
|
||||
actorId: string,
|
||||
agentMap?: Map<string, Agent>,
|
||||
currentUserId?: string | null,
|
||||
) {
|
||||
if (actorType === "agent") {
|
||||
return agentMap?.get(actorId)?.name ?? actorId.slice(0, 8);
|
||||
}
|
||||
if (actorType === "system") {
|
||||
return "System";
|
||||
}
|
||||
return formatAssigneeUserLabel(actorId, currentUserId) ?? "Board";
|
||||
}
|
||||
|
||||
function initialsForName(name: string) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function formatRunStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case "timed_out":
|
||||
return "timed out";
|
||||
default:
|
||||
return status.replace(/_/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
function runTimestamp(run: LinkedRunItem) {
|
||||
return run.finishedAt ?? run.startedAt ?? run.createdAt;
|
||||
}
|
||||
|
||||
function runStatusClass(status: string) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "text-green-700 dark:text-green-300";
|
||||
case "failed":
|
||||
case "error":
|
||||
return "text-red-700 dark:text-red-300";
|
||||
case "timed_out":
|
||||
return "text-orange-700 dark:text-orange-300";
|
||||
case "running":
|
||||
return "text-cyan-700 dark:text-cyan-300";
|
||||
case "queued":
|
||||
case "pending":
|
||||
return "text-amber-700 dark:text-amber-300";
|
||||
case "cancelled":
|
||||
return "text-muted-foreground";
|
||||
default:
|
||||
return "text-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function CopyMarkdownButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
|
|
@ -277,11 +360,76 @@ function CommentCard({
|
|||
|
||||
type TimelineItem =
|
||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
|
||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||
|
||||
function TimelineEventCard({
|
||||
event,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
}: {
|
||||
event: IssueTimelineEvent;
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
}) {
|
||||
const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId);
|
||||
|
||||
return (
|
||||
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
|
||||
<Avatar size="sm" className="mt-0.5">
|
||||
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
|
||||
<span className="font-medium text-foreground">{actorName}</span>
|
||||
<span className="text-muted-foreground">updated this task</span>
|
||||
<a
|
||||
href={`#activity-${event.id}`}
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
{timeAgo(event.createdAt)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{event.statusChange ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Status
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{humanizeValue(event.statusChange.from)}
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{humanizeValue(event.statusChange.to)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{event.assigneeChange ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Assignee
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimelineAssigneeLabel(event.assigneeChange.from, agentMap, currentUserId)}
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatTimelineAssigneeLabel(event.assigneeChange.to, agentMap, currentUserId)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TimelineList = memo(function TimelineList({
|
||||
timeline,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
companyId,
|
||||
projectId,
|
||||
feedbackVoteByTargetId,
|
||||
|
|
@ -293,6 +441,7 @@ const TimelineList = memo(function TimelineList({
|
|||
}: {
|
||||
timeline: TimelineItem[];
|
||||
agentMap?: Map<string, Agent>;
|
||||
currentUserId?: string | null;
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
|
||||
|
|
@ -307,36 +456,54 @@ const TimelineList = memo(function TimelineList({
|
|||
highlightCommentId?: string | null;
|
||||
}) {
|
||||
if (timeline.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No comments or runs yet.</p>;
|
||||
return <p className="text-sm text-muted-foreground">No timeline entries yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "event") {
|
||||
return (
|
||||
<TimelineEventCard
|
||||
key={`event:${item.event.id}`}
|
||||
event={item.event}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "run") {
|
||||
const run = item.run;
|
||||
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
return (
|
||||
<div key={`run:${run.runId}`} className="border border-border bg-accent/20 p-3 overflow-hidden min-w-0 rounded-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Link to={`/agents/${run.agentId}`} className="hover:underline">
|
||||
<Identity
|
||||
name={agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8)}
|
||||
size="sm"
|
||||
/>
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(run.startedAt ?? run.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Run</span>
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.runId}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
|
||||
>
|
||||
{run.runId.slice(0, 8)}
|
||||
</Link>
|
||||
<StatusBadge status={run.status} />
|
||||
<div id={`run-${run.runId}`} key={`run:${run.runId}`} className="flex items-center gap-2.5 py-1.5">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
|
||||
<Link to={`/agents/${run.agentId}`} className="font-medium text-foreground transition-colors hover:underline">
|
||||
{actorName}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">run</span>
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.runId}`}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
{run.runId.slice(0, 8)}
|
||||
</Link>
|
||||
<span className={cn("font-medium", runStatusClass(run.status))}>
|
||||
{formatRunStatusLabel(run.status)}
|
||||
</span>
|
||||
<a
|
||||
href={`#run-${run.runId}`}
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
{timeAgo(runTimestamp(run))}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -370,11 +537,13 @@ export function CommentThread({
|
|||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
linkedRuns = [],
|
||||
timelineEvents = [],
|
||||
companyId,
|
||||
projectId,
|
||||
onVote,
|
||||
onAdd,
|
||||
agentMap,
|
||||
currentUserId,
|
||||
imageUploadHandler,
|
||||
onAttachImage,
|
||||
draftKey,
|
||||
|
|
@ -408,18 +577,29 @@ export function CommentThread({
|
|||
createdAtMs: new Date(comment.createdAt).getTime(),
|
||||
comment,
|
||||
}));
|
||||
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
|
||||
kind: "event",
|
||||
id: event.id,
|
||||
createdAtMs: new Date(event.createdAt).getTime(),
|
||||
event,
|
||||
}));
|
||||
const runItems: TimelineItem[] = linkedRuns.map((run) => ({
|
||||
kind: "run",
|
||||
id: run.runId,
|
||||
createdAtMs: new Date(run.startedAt ?? run.createdAt).getTime(),
|
||||
createdAtMs: new Date(runTimestamp(run)).getTime(),
|
||||
run,
|
||||
}));
|
||||
return [...commentItems, ...runItems].sort((a, b) => {
|
||||
return [...commentItems, ...eventItems, ...runItems].sort((a, b) => {
|
||||
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
|
||||
if (a.kind === b.kind) return a.id.localeCompare(b.id);
|
||||
return a.kind === "comment" ? -1 : 1;
|
||||
const kindOrder = {
|
||||
event: 0,
|
||||
comment: 1,
|
||||
run: 2,
|
||||
} as const;
|
||||
return kindOrder[a.kind] - kindOrder[b.kind];
|
||||
});
|
||||
}, [comments, linkedRuns]);
|
||||
}, [comments, timelineEvents, linkedRuns]);
|
||||
|
||||
const feedbackVoteByTargetId = useMemo(() => {
|
||||
const map = new Map<string, FeedbackVoteValue>();
|
||||
|
|
@ -496,7 +676,6 @@ export function CommentThread({
|
|||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
// TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI.
|
||||
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
setReopen(true);
|
||||
|
|
@ -551,11 +730,12 @@ export function CommentThread({
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Comments & Runs ({timeline.length + queuedComments.length})</h3>
|
||||
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
|
||||
|
||||
<TimelineList
|
||||
timeline={timeline}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
feedbackVoteByTargetId={feedbackVoteByTargetId}
|
||||
|
|
|
|||
151
ui/src/components/ImageGalleryModal.tsx
Normal file
151
ui/src/components/ImageGalleryModal.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
|
||||
import type { IssueAttachment } from "@paperclipai/shared";
|
||||
|
||||
interface ImageGalleryModalProps {
|
||||
images: IssueAttachment[];
|
||||
initialIndex: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ImageGalleryModal({
|
||||
images,
|
||||
initialIndex,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ImageGalleryModalProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setCurrentIndex(initialIndex);
|
||||
}, [open, initialIndex]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setCurrentIndex((i) => (i + 1) % images.length);
|
||||
}, [images.length]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setCurrentIndex((i) => (i - 1 + images.length) % images.length);
|
||||
}, [images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight") goNext();
|
||||
else if (e.key === "ArrowLeft") goPrev();
|
||||
else if (e.key === "Escape") onOpenChange(false);
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, goNext, goPrev, onOpenChange]);
|
||||
|
||||
/** Close when clicking empty curtain space (not interactive elements or the image) */
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest("button") ||
|
||||
target.closest("a") ||
|
||||
target === imageRef.current
|
||||
)
|
||||
return;
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
const current = images[currentIndex];
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
{/* Full-screen curtain */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/90 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200" />
|
||||
<DialogPrimitive.Content
|
||||
className="fixed inset-0 z-50 flex flex-col outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between px-5 py-3 text-white/80 text-sm shrink-0">
|
||||
<span className="truncate max-w-[50%] font-medium" title={current.originalFilename ?? undefined}>
|
||||
{current.originalFilename ?? "Image"}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-white/40 tabular-nums text-xs">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</span>
|
||||
<a
|
||||
href={current.contentPath}
|
||||
download={current.originalFilename ?? "image"}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
title="Download"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Download className="h-4.5 w-4.5" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="text-white/50 hover:text-white transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main area: nav buttons outside image */}
|
||||
<div className="flex-1 flex items-center min-h-0">
|
||||
{/* Left nav zone */}
|
||||
<div className="w-16 md:w-24 shrink-0 flex items-center justify-center h-full">
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
className="rounded-full bg-white/10 p-3 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
|
||||
title="Previous"
|
||||
>
|
||||
<ChevronLeft className="h-7 w-7" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center min-w-0 min-h-0 h-full px-2">
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={current.contentPath}
|
||||
alt={current.originalFilename ?? "attachment"}
|
||||
className="max-w-full max-h-full object-contain select-none rounded-lg"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right nav zone */}
|
||||
<div className="w-16 md:w-24 shrink-0 flex items-center justify-center h-full">
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="rounded-full bg-white/10 p-3 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
|
||||
title="Next"
|
||||
>
|
||||
<ChevronRight className="h-7 w-7" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom padding for balance */}
|
||||
<div className="h-6 shrink-0" />
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
354
ui/src/components/IssueDocumentsSection.test.tsx
Normal file
354
ui/src/components/IssueDocumentsSection.test.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueDocumentsSection } from "./IssueDocumentsSection";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
listDocuments: vi.fn(),
|
||||
listDocumentRevisions: vi.fn(),
|
||||
restoreDocumentRevision: vi.fn(),
|
||||
upsertDocument: vi.fn(),
|
||||
deleteDocument: vi.fn(),
|
||||
getDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
const markdownEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyChange: false,
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: mockIssuesApi,
|
||||
}));
|
||||
|
||||
vi.mock("../hooks/useAutosaveIndicator", () => ({
|
||||
useAutosaveIndicator: () => ({
|
||||
state: "idle",
|
||||
markDirty: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
runSave: async (save: () => Promise<unknown>) => save(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useLocation: () => ({ hash: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children, className }: { children: string; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./MarkdownEditor", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
return {
|
||||
MarkdownEditor: ({ value, onChange, placeholder, contentClassName }: {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
contentClassName?: string;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (!markdownEditorMockState.emitMountEmptyChange) return;
|
||||
onChange?.("");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={contentClassName} data-testid="markdown-editor">
|
||||
{value || placeholder || ""}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({
|
||||
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
|
||||
<button type={type} onClick={onClick} {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/input", () => ({
|
||||
Input: (props: ComponentProps<"input">) => <input {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", async () => {
|
||||
return {
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuItem: ({ children, onClick, onSelect, disabled }: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onSelect?.();
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuRadioItem: ({ children, onSelect, disabled }: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button type="button" disabled={disabled} onClick={() => onSelect?.()}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
};
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
function createIssueDocument(overrides: Partial<IssueDocument> = {}): IssueDocument {
|
||||
return {
|
||||
id: "document-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "",
|
||||
latestRevisionId: "revision-4",
|
||||
latestRevisionNumber: 4,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
updatedByAgentId: null,
|
||||
updatedByUserId: "user-1",
|
||||
createdAt: new Date("2026-03-31T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-31T12:05:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createRevision(overrides: Partial<DocumentRevision> = {}): DocumentRevision {
|
||||
return {
|
||||
id: "revision-3",
|
||||
companyId: "company-1",
|
||||
documentId: "document-1",
|
||||
issueId: "issue-1",
|
||||
key: "plan",
|
||||
revisionNumber: 3,
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body: "Restored plan body",
|
||||
changeSummary: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: new Date("2026-03-31T11:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createIssue(): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-807",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Plan rendering",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
issueNumber: 807,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
labels: [],
|
||||
labelIds: [],
|
||||
planDocument: createIssueDocument(),
|
||||
documentSummaries: [createIssueDocument()],
|
||||
legacyPlanDocument: null,
|
||||
createdAt: new Date("2026-03-31T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-31T12:05:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueDocumentsSection", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
window.localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
markdownEditorMockState.emitMountEmptyChange = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("shows the restored document body immediately after a revision restore", async () => {
|
||||
const blankLatestDocument = createIssueDocument({
|
||||
body: "",
|
||||
latestRevisionId: "revision-4",
|
||||
latestRevisionNumber: 4,
|
||||
});
|
||||
const restoredDocument = createIssueDocument({
|
||||
body: "Restored plan body",
|
||||
latestRevisionId: "revision-5",
|
||||
latestRevisionNumber: 5,
|
||||
updatedAt: new Date("2026-03-31T12:06:00.000Z"),
|
||||
});
|
||||
const pendingDocuments = deferred<IssueDocument[]>();
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockIssuesApi.listDocuments
|
||||
.mockResolvedValueOnce([blankLatestDocument])
|
||||
.mockImplementation(() => pendingDocuments.promise);
|
||||
mockIssuesApi.restoreDocumentRevision.mockResolvedValue(restoredDocument);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.issues.documentRevisions(issue.id, "plan"),
|
||||
[
|
||||
createRevision({ id: "revision-4", revisionNumber: 4, body: "", createdAt: new Date("2026-03-31T12:05:00.000Z") }),
|
||||
createRevision(),
|
||||
],
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).not.toContain("Restored plan body");
|
||||
|
||||
const revisionButtons = Array.from(container.querySelectorAll("button"));
|
||||
const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3"));
|
||||
expect(historicalRevisionButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Viewing revision 3");
|
||||
expect(container.textContent).toContain("Restored plan body");
|
||||
|
||||
const restoreButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Restore this revision"));
|
||||
expect(restoreButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
restoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(mockIssuesApi.restoreDocumentRevision).toHaveBeenCalledWith("issue-1", "plan", "revision-3");
|
||||
expect(container.textContent).toContain("Restored plan body");
|
||||
expect(container.textContent).not.toContain("Viewing revision 3");
|
||||
|
||||
pendingDocuments.resolve([restoredDocument]);
|
||||
await flush();
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("ignores mount-time editor change noise before a document is actively being edited", async () => {
|
||||
markdownEditorMockState.emitMountEmptyChange = true;
|
||||
|
||||
const document = createIssueDocument({
|
||||
body: "Loaded plan body",
|
||||
});
|
||||
const issue = createIssue();
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockIssuesApi.listDocuments.mockResolvedValue([document]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Loaded plan body");
|
||||
expect(container.textContent).not.toContain("Markdown body");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
|
@ -29,7 +29,7 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||
|
||||
type DraftState = {
|
||||
key: string;
|
||||
|
|
@ -106,6 +106,25 @@ function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null)
|
|||
return draft.body !== doc.body || (doc.title ?? "") !== draft.title;
|
||||
}
|
||||
|
||||
function toDocumentSummary(document: IssueDocument) {
|
||||
return {
|
||||
id: document.id,
|
||||
companyId: document.companyId,
|
||||
issueId: document.issueId,
|
||||
key: document.key,
|
||||
title: document.title,
|
||||
format: document.format,
|
||||
latestRevisionId: document.latestRevisionId,
|
||||
latestRevisionNumber: document.latestRevisionNumber,
|
||||
createdByAgentId: document.createdByAgentId,
|
||||
createdByUserId: document.createdByUserId,
|
||||
updatedByAgentId: document.updatedByAgentId,
|
||||
updatedByUserId: document.updatedByUserId,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function IssueDocumentsSection({
|
||||
issue,
|
||||
canDeleteDocuments,
|
||||
|
|
@ -181,6 +200,36 @@ export function IssueDocumentsSection({
|
|||
});
|
||||
}, [issue.id, queryClient]);
|
||||
|
||||
const syncDocumentCaches = useCallback((document: IssueDocument) => {
|
||||
queryClient.setQueryData<IssueDocument[] | undefined>(
|
||||
queryKeys.issues.documents(issue.id),
|
||||
(current) => {
|
||||
if (!current) return [document];
|
||||
const existingIndex = current.findIndex((entry) => entry.key === document.key);
|
||||
if (existingIndex === -1) return [...current, document];
|
||||
return current.map((entry, index) => index === existingIndex ? document : entry);
|
||||
},
|
||||
);
|
||||
queryClient.setQueryData<Issue | undefined>(
|
||||
queryKeys.issues.detail(issue.id),
|
||||
(current) => {
|
||||
if (!current) return current;
|
||||
const nextSummaries = (() => {
|
||||
const summary = toDocumentSummary(document);
|
||||
const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key);
|
||||
if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary];
|
||||
return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry);
|
||||
})();
|
||||
return {
|
||||
...current,
|
||||
planDocument: document.key === "plan" ? document : current.planDocument ?? null,
|
||||
documentSummaries: nextSummaries,
|
||||
legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null,
|
||||
};
|
||||
},
|
||||
);
|
||||
}, [issue.id, queryClient]);
|
||||
|
||||
const upsertDocument = useMutation({
|
||||
mutationFn: async (nextDraft: DraftState) =>
|
||||
issuesApi.upsertDocument(issue.id, nextDraft.key, {
|
||||
|
|
@ -206,7 +255,8 @@ export function IssueDocumentsSection({
|
|||
const restoreDocumentRevision = useMutation({
|
||||
mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) =>
|
||||
issuesApi.restoreDocumentRevision(issue.id, key, revisionId),
|
||||
onSuccess: (_document, variables) => {
|
||||
onSuccess: (document, variables) => {
|
||||
syncDocumentCaches(document);
|
||||
setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null }));
|
||||
setDraft((current) => current?.key === variables.key ? null : current);
|
||||
setDocumentConflict((current) => current?.key === variables.key ? null : current);
|
||||
|
|
@ -369,6 +419,7 @@ export function IssueDocumentsSection({
|
|||
isNew: false,
|
||||
};
|
||||
});
|
||||
syncDocumentCaches(saved);
|
||||
invalidateIssueDocuments();
|
||||
};
|
||||
|
||||
|
|
@ -408,7 +459,7 @@ export function IssueDocumentsSection({
|
|||
setError(err instanceof Error ? err.message : "Failed to save document");
|
||||
return false;
|
||||
}
|
||||
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
|
||||
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]);
|
||||
|
||||
const reloadDocumentFromServer = useCallback((key: string) => {
|
||||
if (documentConflict?.key !== key) return;
|
||||
|
|
@ -864,7 +915,14 @@ export function IssueDocumentsSection({
|
|||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end">
|
||||
{!isHistoricalPreview ? (
|
||||
<DropdownMenuItem onClick={() => beginEdit(doc.key)}>
|
||||
<FilePenLine className="h-3.5 w-3.5" />
|
||||
Edit document
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{!isHistoricalPreview ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
|
||||
>
|
||||
|
|
@ -889,13 +947,6 @@ export function IssueDocumentsSection({
|
|||
{!isFolded ? (
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onFocusCapture={!isHistoricalPreview
|
||||
? () => {
|
||||
if (!activeDraft) {
|
||||
beginEdit(doc.key);
|
||||
}
|
||||
}
|
||||
: undefined}
|
||||
onBlurCapture={!isHistoricalPreview
|
||||
? async (event) => {
|
||||
if (activeDraft) {
|
||||
|
|
@ -1026,7 +1077,7 @@ export function IssueDocumentsSection({
|
|||
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
|
||||
{renderBody(displayedBody, documentBodyContentClassName)}
|
||||
</div>
|
||||
) : (
|
||||
) : activeDraft ? (
|
||||
<MarkdownEditor
|
||||
value={displayedBody}
|
||||
onChange={(body) => {
|
||||
|
|
@ -1035,13 +1086,7 @@ export function IssueDocumentsSection({
|
|||
if (current && current.key === doc.key && !current.isNew) {
|
||||
return { ...current, body };
|
||||
}
|
||||
return {
|
||||
key: doc.key,
|
||||
title: doc.title ?? "",
|
||||
body,
|
||||
baseRevisionId: doc.latestRevisionId,
|
||||
isNew: false,
|
||||
};
|
||||
return current;
|
||||
});
|
||||
}}
|
||||
placeholder="Markdown body"
|
||||
|
|
@ -1052,6 +1097,10 @@ export function IssueDocumentsSection({
|
|||
imageUploadHandler={imageUploadHandler}
|
||||
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/60 bg-background/40 p-3">
|
||||
{renderBody(displayedBody, documentBodyContentClassName)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-4 items-center justify-end px-1">
|
||||
|
|
|
|||
161
ui/src/components/MarkdownEditor.test.tsx
Normal file
161
ui/src/components/MarkdownEditor.test.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
|
||||
const mdxEditorMockState = vi.hoisted(() => ({
|
||||
emitMountEmptyReset: false,
|
||||
}));
|
||||
|
||||
vi.mock("@mdxeditor/editor", async () => {
|
||||
const React = await import("react");
|
||||
|
||||
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
if (ref) {
|
||||
(ref as React.MutableRefObject<T | null>).current = value;
|
||||
}
|
||||
}
|
||||
|
||||
const MDXEditor = React.forwardRef(function MockMDXEditor(
|
||||
{
|
||||
markdown,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
markdown: string;
|
||||
placeholder?: string;
|
||||
onChange?: (value: string) => void;
|
||||
},
|
||||
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
|
||||
) {
|
||||
const [content, setContent] = React.useState(markdown);
|
||||
const handle = React.useMemo(() => ({
|
||||
setMarkdown: (value: string) => setContent(value),
|
||||
focus: () => {},
|
||||
}), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
setForwardedRef(forwardedRef, null);
|
||||
const timer = window.setTimeout(() => {
|
||||
setForwardedRef(forwardedRef, handle);
|
||||
if (mdxEditorMockState.emitMountEmptyReset) {
|
||||
setContent("");
|
||||
onChange?.("");
|
||||
}
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
setForwardedRef(forwardedRef, null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
|
||||
});
|
||||
|
||||
return {
|
||||
CodeMirrorEditor: () => null,
|
||||
MDXEditor,
|
||||
codeBlockPlugin: () => ({}),
|
||||
codeMirrorPlugin: () => ({}),
|
||||
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
|
||||
headingsPlugin: () => ({}),
|
||||
imagePlugin: () => ({}),
|
||||
linkDialogPlugin: () => ({}),
|
||||
linkPlugin: () => ({}),
|
||||
listsPlugin: () => ({}),
|
||||
markdownShortcutPlugin: () => ({}),
|
||||
quotePlugin: () => ({}),
|
||||
realmPlugin: (plugin: unknown) => plugin,
|
||||
tablePlugin: () => ({}),
|
||||
thematicBreakPlugin: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/mention-deletion", () => ({
|
||||
mentionDeletionPlugin: () => ({}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("MarkdownEditor", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
mdxEditorMockState.emitMountEmptyReset = false;
|
||||
});
|
||||
|
||||
it("applies async external value updates once the editor ref becomes ready", async () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="Loaded plan body"
|
||||
onChange={() => {}}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Loaded plan body");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the external value when the unfocused editor emits an empty mount reset", async () => {
|
||||
mdxEditorMockState.emitMountEmptyReset = true;
|
||||
const handleChange = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MarkdownEditor
|
||||
value="Loaded plan body"
|
||||
onChange={handleChange}
|
||||
placeholder="Markdown body"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(container.textContent).toContain("Loaded plan body");
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -199,8 +199,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
onSubmit,
|
||||
}: MarkdownEditorProps, forwardedRef) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<MDXEditorMethods>(null);
|
||||
const editorRef = useRef<MDXEditorMethods | null>(null);
|
||||
const latestValueRef = useRef(value);
|
||||
const latestPropValueRef = useRef(value);
|
||||
const pendingExternalValueRef = useRef<string | null>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
|
@ -236,7 +239,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
focus: () => {
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
},
|
||||
}), []);
|
||||
|
||||
|
|
@ -263,10 +266,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
);
|
||||
if (updated !== current) {
|
||||
latestValueRef.current = updated;
|
||||
ref.current?.setMarkdown(updated);
|
||||
editorRef.current?.setMarkdown(updated);
|
||||
onChange(updated);
|
||||
requestAnimationFrame(() => {
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
editorRef.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
|
@ -300,10 +303,29 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
return all;
|
||||
}, [hasImageUpload]);
|
||||
|
||||
const handleEditorRef = useCallback((instance: MDXEditorMethods | null) => {
|
||||
editorRef.current = instance;
|
||||
if (!instance) return;
|
||||
|
||||
const pendingValue = pendingExternalValueRef.current;
|
||||
if (pendingValue !== null && pendingValue !== latestValueRef.current) {
|
||||
instance.setMarkdown(pendingValue);
|
||||
latestValueRef.current = pendingValue;
|
||||
}
|
||||
pendingExternalValueRef.current = null;
|
||||
}, []);
|
||||
|
||||
latestPropValueRef.current = value;
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== latestValueRef.current) {
|
||||
ref.current?.setMarkdown(value);
|
||||
if (!editorRef.current) {
|
||||
pendingExternalValueRef.current = value;
|
||||
return;
|
||||
}
|
||||
editorRef.current.setMarkdown(value);
|
||||
latestValueRef.current = value;
|
||||
pendingExternalValueRef.current = null;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
|
|
@ -394,7 +416,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const next = applyMention(current, state.query, option);
|
||||
if (next !== current) {
|
||||
latestValueRef.current = next;
|
||||
ref.current?.setMarkdown(next);
|
||||
editorRef.current?.setMarkdown(next);
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
|
|
@ -541,12 +563,35 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
isFocusedRef.current = true;
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
isFocusedRef.current = false;
|
||||
}}
|
||||
>
|
||||
<MDXEditor
|
||||
ref={ref}
|
||||
ref={handleEditorRef}
|
||||
markdown={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(next) => {
|
||||
const externalValue = latestPropValueRef.current;
|
||||
if (!isFocusedRef.current) {
|
||||
if (next === externalValue) {
|
||||
latestValueRef.current = externalValue;
|
||||
return;
|
||||
}
|
||||
|
||||
latestValueRef.current = externalValue;
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setMarkdown(externalValue);
|
||||
pendingExternalValueRef.current = null;
|
||||
} else {
|
||||
pendingExternalValueRef.current = externalValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
latestValueRef.current = next;
|
||||
onChange(next);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
|||
workspaceBranchTemplate: "",
|
||||
worktreeParentDir: "",
|
||||
runtimeServicesJson: "",
|
||||
maxTurnsPerRun: 300,
|
||||
maxTurnsPerRun: 1000,
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 300,
|
||||
};
|
||||
|
|
|
|||
153
ui/src/lib/issue-timeline-events.test.ts
Normal file
153
ui/src/lib/issue-timeline-events.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { extractIssueTimelineEvents } from "./issue-timeline-events";
|
||||
|
||||
describe("extractIssueTimelineEvents", () => {
|
||||
it("extracts and sorts status and assignee changes from issue updates", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-2",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
details: {
|
||||
assigneeAgentId: "agent-2",
|
||||
assigneeUserId: null,
|
||||
_previous: {
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-1",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
status: "in_progress",
|
||||
_previous: {
|
||||
status: "todo",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-ignored",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:03:00.000Z"),
|
||||
details: {
|
||||
commentId: "comment-1",
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: "evt-1",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
statusChange: {
|
||||
from: "todo",
|
||||
to: "in_progress",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
createdAt: new Date("2026-03-31T12:02:00.000Z"),
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
assigneeChange: {
|
||||
from: {
|
||||
agentId: "agent-1",
|
||||
userId: null,
|
||||
},
|
||||
to: {
|
||||
agentId: "agent-2",
|
||||
userId: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses reopenedFrom when a reopen update omits _previous", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-reopen",
|
||||
companyId: "company-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
status: "todo",
|
||||
reopened: true,
|
||||
reopenedFrom: "done",
|
||||
source: "comment",
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
id: "evt-reopen",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
statusChange: {
|
||||
from: "done",
|
||||
to: "todo",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores issue updates without visible status or assignee transitions", () => {
|
||||
const events = extractIssueTimelineEvents([
|
||||
{
|
||||
id: "evt-title",
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
details: {
|
||||
title: "New title",
|
||||
_previous: {
|
||||
title: "Old title",
|
||||
},
|
||||
},
|
||||
},
|
||||
] satisfies ActivityEvent[]);
|
||||
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
105
ui/src/lib/issue-timeline-events.ts
Normal file
105
ui/src/lib/issue-timeline-events.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
|
||||
export interface IssueTimelineAssignee {
|
||||
agentId: string | null;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
export interface IssueTimelineEvent {
|
||||
id: string;
|
||||
createdAt: Date | string;
|
||||
actorType: ActivityEvent["actorType"];
|
||||
actorId: string;
|
||||
statusChange?: {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
};
|
||||
assigneeChange?: {
|
||||
from: IssueTimelineAssignee;
|
||||
to: IssueTimelineAssignee;
|
||||
};
|
||||
}
|
||||
|
||||
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 hasOwn(record: Record<string, unknown>, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function toTimestamp(value: Date | string) {
|
||||
return new Date(value).getTime();
|
||||
}
|
||||
|
||||
function sameAssignee(left: IssueTimelineAssignee, right: IssueTimelineAssignee) {
|
||||
return left.agentId === right.agentId && left.userId === right.userId;
|
||||
}
|
||||
|
||||
function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(events: T[]) {
|
||||
return [...events].sort((a, b) => {
|
||||
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
|
||||
if (createdAtDiff !== 0) return createdAtDiff;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | undefined): IssueTimelineEvent[] {
|
||||
const events: IssueTimelineEvent[] = [];
|
||||
|
||||
for (const event of activity ?? []) {
|
||||
if (event.action !== "issue.updated") continue;
|
||||
|
||||
const details = asRecord(event.details);
|
||||
if (!details) continue;
|
||||
|
||||
const previous = asRecord(details._previous);
|
||||
const timelineEvent: IssueTimelineEvent = {
|
||||
id: event.id,
|
||||
createdAt: event.createdAt,
|
||||
actorType: event.actorType,
|
||||
actorId: event.actorId,
|
||||
};
|
||||
|
||||
if (hasOwn(details, "status")) {
|
||||
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
|
||||
const to = nullableString(details.status);
|
||||
if (from !== to) {
|
||||
timelineEvent.statusChange = { from, to };
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOwn(details, "assigneeAgentId") || hasOwn(details, "assigneeUserId")) {
|
||||
const previousAssignee: IssueTimelineAssignee = {
|
||||
agentId: nullableString(previous?.assigneeAgentId),
|
||||
userId: nullableString(previous?.assigneeUserId),
|
||||
};
|
||||
const nextAssignee: IssueTimelineAssignee = {
|
||||
agentId: hasOwn(details, "assigneeAgentId")
|
||||
? nullableString(details.assigneeAgentId)
|
||||
: previousAssignee.agentId,
|
||||
userId: hasOwn(details, "assigneeUserId")
|
||||
? nullableString(details.assigneeUserId)
|
||||
: previousAssignee.userId,
|
||||
};
|
||||
|
||||
if (!sameAssignee(previousAssignee, nextAssignee)) {
|
||||
timelineEvent.assigneeChange = {
|
||||
from: previousAssignee,
|
||||
to: nextAssignee,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
|
||||
events.push(timelineEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return sortTimelineEvents(events);
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
armIssueDetailInboxQuickArchive,
|
||||
createIssueDetailLocationState,
|
||||
createIssueDetailPath,
|
||||
readIssueDetailBreadcrumb,
|
||||
shouldArmIssueDetailInboxQuickArchive,
|
||||
} from "./issueDetailBreadcrumb";
|
||||
|
||||
describe("issueDetailBreadcrumb", () => {
|
||||
|
|
@ -25,10 +27,30 @@ describe("issueDetailBreadcrumb", () => {
|
|||
it("adds the source query param when building an issue detail path", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox");
|
||||
expect(createIssueDetailPath("PAP-465", state)).toBe(
|
||||
"/issues/PAP-465?from=inbox&fromHref=%2Finbox%2Fmine",
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the current source query param when state has been dropped", () => {
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues");
|
||||
expect(createIssueDetailPath("PAP-465", null, "?from=issues&fromHref=%2Fissues%3Fq%3Dabc")).toBe(
|
||||
"/issues/PAP-465?from=issues&fromHref=%2Fissues%3Fq%3Dabc",
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the exact breadcrumb href from the query fallback", () => {
|
||||
expect(
|
||||
readIssueDetailBreadcrumb(null, "?from=inbox&fromHref=%2FPAP%2Finbox%2Funread"),
|
||||
).toEqual({
|
||||
label: "Inbox",
|
||||
href: "/PAP/inbox/unread",
|
||||
});
|
||||
});
|
||||
|
||||
it("can arm quick archive only for explicit inbox keyboard entry state", () => {
|
||||
const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
|
||||
expect(shouldArmIssueDetailInboxQuickArchive(state)).toBe(false);
|
||||
expect(shouldArmIssueDetailInboxQuickArchive(armIssueDetailInboxQuickArchive(state))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ type IssueDetailBreadcrumb = {
|
|||
type IssueDetailLocationState = {
|
||||
issueDetailBreadcrumb?: IssueDetailBreadcrumb;
|
||||
issueDetailSource?: IssueDetailSource;
|
||||
issueDetailInboxQuickArchiveArmed?: boolean;
|
||||
};
|
||||
|
||||
const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from";
|
||||
const ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM = "fromHref";
|
||||
|
||||
function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
|
|
@ -35,6 +37,13 @@ function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | n
|
|||
return isIssueDetailSource(source) ? source : null;
|
||||
}
|
||||
|
||||
function readIssueDetailBreadcrumbHrefFromSearch(search?: string): string | null {
|
||||
if (!search) return null;
|
||||
const params = new URLSearchParams(search);
|
||||
const href = params.get(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM);
|
||||
return href && href.startsWith("/") ? href : null;
|
||||
}
|
||||
|
||||
function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb {
|
||||
if (source === "inbox") return { label: "Inbox", href: "/inbox" };
|
||||
return { label: "Issues", href: "/issues" };
|
||||
|
|
@ -51,11 +60,30 @@ export function createIssueDetailLocationState(
|
|||
};
|
||||
}
|
||||
|
||||
export function armIssueDetailInboxQuickArchive(state: unknown): IssueDetailLocationState {
|
||||
if (typeof state !== "object" || state === null) {
|
||||
return { issueDetailInboxQuickArchiveArmed: true };
|
||||
}
|
||||
|
||||
return {
|
||||
...(state as IssueDetailLocationState),
|
||||
issueDetailInboxQuickArchiveArmed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string {
|
||||
const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search);
|
||||
const breadcrumb =
|
||||
(typeof state === "object" && state !== null
|
||||
? (state as IssueDetailLocationState).issueDetailBreadcrumb
|
||||
: null);
|
||||
const breadcrumbHref =
|
||||
(isIssueDetailBreadcrumb(breadcrumb) ? breadcrumb.href : null) ??
|
||||
readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||
if (!source) return `/issues/${issuePathId}`;
|
||||
const params = new URLSearchParams();
|
||||
params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source);
|
||||
if (breadcrumbHref) params.set(ISSUE_DETAIL_BREADCRUMB_HREF_QUERY_PARAM, breadcrumbHref);
|
||||
return `/issues/${issuePathId}?${params.toString()}`;
|
||||
}
|
||||
|
||||
|
|
@ -66,5 +94,14 @@ export function readIssueDetailBreadcrumb(state: unknown, search?: string): Issu
|
|||
}
|
||||
|
||||
const source = readIssueDetailSourceFromSearch(search);
|
||||
return source ? breadcrumbForSource(source) : null;
|
||||
if (!source) return null;
|
||||
|
||||
const fallback = breadcrumbForSource(source);
|
||||
const href = readIssueDetailBreadcrumbHrefFromSearch(search);
|
||||
return href ? { ...fallback, href } : fallback;
|
||||
}
|
||||
|
||||
export function shouldArmIssueDetailInboxQuickArchive(state: unknown): boolean {
|
||||
if (typeof state !== "object" || state === null) return false;
|
||||
return (state as IssueDetailLocationState).issueDetailInboxQuickArchiveArmed === true;
|
||||
}
|
||||
|
|
|
|||
106
ui/src/lib/keyboardShortcuts.test.ts
Normal file
106
ui/src/lib/keyboardShortcuts.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
isKeyboardShortcutTextInputTarget,
|
||||
resolveInboxQuickArchiveKeyAction,
|
||||
} from "./keyboardShortcuts";
|
||||
|
||||
describe("keyboardShortcuts helpers", () => {
|
||||
it("detects editable shortcut targets", () => {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = `
|
||||
<div contenteditable="true"><span id="contenteditable-child">Editable</span></div>
|
||||
<div role="textbox"><span id="textbox-child">Textbox</span></div>
|
||||
<button id="button">Action</button>
|
||||
`;
|
||||
|
||||
const editableChild = wrapper.querySelector("#contenteditable-child");
|
||||
const textboxChild = wrapper.querySelector("#textbox-child");
|
||||
const button = wrapper.querySelector("#button");
|
||||
|
||||
expect(isKeyboardShortcutTextInputTarget(editableChild)).toBe(true);
|
||||
expect(isKeyboardShortcutTextInputTarget(textboxChild)).toBe(true);
|
||||
expect(isKeyboardShortcutTextInputTarget(button)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports when a modal dialog is open", () => {
|
||||
const root = document.createElement("div");
|
||||
root.innerHTML = `<div role="dialog" aria-modal="true"></div>`;
|
||||
|
||||
expect(hasBlockingShortcutDialog(root)).toBe(true);
|
||||
expect(hasBlockingShortcutDialog(document.createElement("div"))).toBe(false);
|
||||
});
|
||||
|
||||
it("archives only the first clean y press", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "y",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("archive");
|
||||
});
|
||||
|
||||
it("disarms on the first non-y keypress", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "n",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("disarm");
|
||||
});
|
||||
|
||||
it("stays inert for modifier combos before a real keypress", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "Meta",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("ignore");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "y",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: button,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("ignore");
|
||||
});
|
||||
|
||||
it("disarms instead of archiving when typing into an editor", () => {
|
||||
const input = document.createElement("input");
|
||||
|
||||
expect(resolveInboxQuickArchiveKeyAction({
|
||||
armed: true,
|
||||
defaultPrevented: false,
|
||||
key: "y",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
target: input,
|
||||
hasOpenDialog: false,
|
||||
})).toBe("disarm");
|
||||
});
|
||||
});
|
||||
54
ui/src/lib/keyboardShortcuts.ts
Normal file
54
ui/src/lib/keyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export const KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR = [
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"[contenteditable='true']",
|
||||
"[contenteditable='plaintext-only']",
|
||||
"[role='textbox']",
|
||||
"[role='combobox']",
|
||||
].join(", ");
|
||||
|
||||
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Meta", "Control", "Alt"]);
|
||||
|
||||
export type InboxQuickArchiveKeyAction = "ignore" | "archive" | "disarm";
|
||||
|
||||
export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
return !!target.closest(KEYBOARD_SHORTCUT_TEXT_INPUT_SELECTOR);
|
||||
}
|
||||
|
||||
export function hasBlockingShortcutDialog(root: ParentNode = document): boolean {
|
||||
return !!root.querySelector("[role='dialog'], [aria-modal='true']");
|
||||
}
|
||||
|
||||
export function isModifierOnlyKey(key: string): boolean {
|
||||
return MODIFIER_ONLY_KEYS.has(key);
|
||||
}
|
||||
|
||||
export function resolveInboxQuickArchiveKeyAction({
|
||||
armed,
|
||||
defaultPrevented,
|
||||
key,
|
||||
metaKey,
|
||||
ctrlKey,
|
||||
altKey,
|
||||
target,
|
||||
hasOpenDialog,
|
||||
}: {
|
||||
armed: boolean;
|
||||
defaultPrevented: boolean;
|
||||
key: string;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
altKey: boolean;
|
||||
target: EventTarget | null;
|
||||
hasOpenDialog: boolean;
|
||||
}): InboxQuickArchiveKeyAction {
|
||||
if (!armed) return "ignore";
|
||||
if (defaultPrevented) return "disarm";
|
||||
if (metaKey || ctrlKey || altKey || isModifierOnlyKey(key)) return "ignore";
|
||||
if (hasOpenDialog || isKeyboardShortcutTextInputTarget(target)) return "disarm";
|
||||
if (key === "y") return "archive";
|
||||
return "disarm";
|
||||
}
|
||||
|
|
@ -5,17 +5,23 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||
import { issuesApi } from "../api/issues";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import {
|
||||
createIssueDetailPath,
|
||||
readIssueDetailBreadcrumb,
|
||||
shouldArmIssueDetailInboxQuickArchive,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
|
||||
import {
|
||||
applyOptimisticIssueCommentUpdate,
|
||||
createOptimisticIssueComment,
|
||||
|
|
@ -34,6 +40,7 @@ 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";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
|
|
@ -287,6 +294,8 @@ export function IssueDetail() {
|
|||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const [galleryIndex, setGalleryIndex] = useState(0);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
||||
|
|
@ -400,6 +409,7 @@ export function IssueDetail() {
|
|||
enabled: !!issueId,
|
||||
retry: false,
|
||||
});
|
||||
const keyboardShortcutsEnabled = instanceGeneralSettings?.keyboardShortcuts === true;
|
||||
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: projects ?? [],
|
||||
|
|
@ -545,6 +555,10 @@ export function IssueDetail() {
|
|||
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
|
||||
[commentsWithRunMeta],
|
||||
);
|
||||
const timelineEvents = useMemo(
|
||||
() => extractIssueTimelineEvents(activity),
|
||||
[activity],
|
||||
);
|
||||
|
||||
const issueCostSummary = useMemo(() => {
|
||||
let input = 0;
|
||||
|
|
@ -913,6 +927,22 @@ export function IssueDetail() {
|
|||
},
|
||||
});
|
||||
|
||||
const archiveFromInbox = useMutation({
|
||||
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
||||
onSuccess: () => {
|
||||
invalidateIssue();
|
||||
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true });
|
||||
pushToast({ title: "Issue archived from inbox", tone: "success" });
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Archive failed",
|
||||
body: err instanceof Error ? err.message : "Unable to archive this issue from the inbox",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||
setBreadcrumbs([
|
||||
|
|
@ -947,6 +977,76 @@ export function IssueDetail() {
|
|||
return () => closePanel();
|
||||
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const inboxQuickArchiveArmedRef = useRef(false);
|
||||
const canQuickArchiveFromInbox =
|
||||
keyboardShortcutsEnabled &&
|
||||
!issue?.hiddenAt &&
|
||||
sourceBreadcrumb.href.startsWith("/inbox") &&
|
||||
shouldArmIssueDetailInboxQuickArchive(location.state);
|
||||
|
||||
useEffect(() => {
|
||||
if (!issue?.id || !canQuickArchiveFromInbox) {
|
||||
inboxQuickArchiveArmedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
inboxQuickArchiveArmedRef.current = true;
|
||||
|
||||
const disarm = () => {
|
||||
inboxQuickArchiveArmedRef.current = false;
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
disarm();
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: FocusEvent) => {
|
||||
if (event.target instanceof HTMLElement && event.target !== document.body) {
|
||||
disarm();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectionChange = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.toString().trim().length === 0) return;
|
||||
disarm();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const action = resolveInboxQuickArchiveKeyAction({
|
||||
armed: inboxQuickArchiveArmedRef.current,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
key: event.key,
|
||||
metaKey: event.metaKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
altKey: event.altKey,
|
||||
target: event.target,
|
||||
hasOpenDialog: hasBlockingShortcutDialog(document),
|
||||
});
|
||||
|
||||
if (action === "ignore") return;
|
||||
|
||||
disarm();
|
||||
if (action !== "archive") return;
|
||||
|
||||
event.preventDefault();
|
||||
if (!archiveFromInbox.isPending) {
|
||||
archiveFromInbox.mutate(issue.id);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("focusin", handleFocusIn, true);
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("focusin", handleFocusIn, true);
|
||||
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]);
|
||||
|
||||
const copyIssueToClipboard = async () => {
|
||||
if (!issue) return;
|
||||
const decodeEntities = (text: string) => {
|
||||
|
|
@ -1000,6 +1100,7 @@ export function IssueDetail() {
|
|||
|
||||
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
||||
const attachmentList = attachments ?? [];
|
||||
const imageAttachments = attachmentList.filter(isImageAttachment);
|
||||
const hasAttachments = attachmentList.length > 0;
|
||||
const attachmentUploadButton = (
|
||||
<>
|
||||
|
|
@ -1338,14 +1439,22 @@ export function IssueDetail() {
|
|||
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{isImageAttachment(attachment) && (
|
||||
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full text-left"
|
||||
onClick={() => {
|
||||
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
|
||||
setGalleryIndex(idx >= 0 ? idx : 0);
|
||||
setGalleryOpen(true);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={attachment.contentPath}
|
||||
alt={attachment.originalFilename ?? "attachment"}
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
|
||||
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1353,6 +1462,13 @@ export function IssueDetail() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<ImageGalleryModal
|
||||
images={imageAttachments}
|
||||
initialIndex={galleryIndex}
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
/>
|
||||
|
||||
<IssueWorkspaceCard
|
||||
issue={issue}
|
||||
project={orderedProjects.find((p) => p.id === issue.projectId) ?? null}
|
||||
|
|
@ -1390,10 +1506,12 @@ export function IssueDetail() {
|
|||
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
||||
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
||||
linkedRuns={timelineRuns}
|
||||
timelineEvents={timelineEvents}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId}
|
||||
issueStatus={issue.status}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
enableReassign
|
||||
reassignOptions={commentReassignOptions}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue