mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
feat(chat): selected-agent conference-room chat surface and live issue-thread updates
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
12b18be5d6
commit
495c3662eb
13 files changed
+831
-174
No files matched your search
@@ -1345,13 +1345,23 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
});
|
||||
|
||||
describe("workspace_finalize accept gate", () => {
|
||||
async function seedAcceptGateFixture() {
|
||||
type AcceptGateInteractionKind = "request_confirmation" | "request_checkbox_confirmation";
|
||||
|
||||
async function seedAcceptGateFixture(options?: {
|
||||
kind?: AcceptGateInteractionKind;
|
||||
sourceRunId?: string | null;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const sourceRunId =
|
||||
options?.sourceRunId === null ? null : options?.sourceRunId ?? randomUUID();
|
||||
const foreignRunId = randomUUID();
|
||||
const kind = options?.kind ?? "request_confirmation";
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -1375,6 +1385,40 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "CodexCoder",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
...(sourceRunId
|
||||
? [
|
||||
{
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T21:55:00.000Z"),
|
||||
finishedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: foreignRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "manual",
|
||||
status: "running",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
},
|
||||
]);
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
@@ -1404,107 +1448,63 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
executionWorkspaceId,
|
||||
});
|
||||
|
||||
const payload = kind === "request_checkbox_confirmation"
|
||||
? {
|
||||
version: 1 as const,
|
||||
prompt: "Which files should be accepted?",
|
||||
options: [
|
||||
{ id: "file-a", label: "a.txt" },
|
||||
{ id: "file-b", label: "b.txt" },
|
||||
],
|
||||
minSelected: 0,
|
||||
maxSelected: 2,
|
||||
}
|
||||
: {
|
||||
version: 1 as const,
|
||||
prompt: "Mark this issue done?",
|
||||
};
|
||||
|
||||
const created = await interactionsSvc.create({
|
||||
id: issueId,
|
||||
companyId,
|
||||
}, {
|
||||
kind: "request_confirmation",
|
||||
kind,
|
||||
continuationPolicy: "wake_assignee",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Mark this issue done?",
|
||||
},
|
||||
sourceRunId,
|
||||
payload,
|
||||
}, {
|
||||
userId: "local-board",
|
||||
});
|
||||
|
||||
return { companyId, projectId, executionWorkspaceId, issueId, goalId, interactionId: created.id };
|
||||
return {
|
||||
companyId,
|
||||
projectId,
|
||||
executionWorkspaceId,
|
||||
issueId,
|
||||
goalId,
|
||||
interactionId: created.id,
|
||||
sourceRunId,
|
||||
foreignRunId,
|
||||
};
|
||||
}
|
||||
|
||||
it("refuses accept when the issue's latest workspace operation is not a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
// A run touched the workspace (prepare) but never recorded workspace_finalize.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("refuses accept when the latest workspace operation is a failed workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
it("allows request_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
||||
await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "worktree_prepare",
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
});
|
||||
|
||||
it("allows accept once a successful workspace_finalize lands as the latest operation", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
phase: "workspace_finalize",
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
@@ -1518,6 +1518,208 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses request_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
||||
await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining(
|
||||
"the run that created this interaction has not finished syncing its workspace",
|
||||
),
|
||||
details: { executionWorkspaceId, sourceRunId },
|
||||
});
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(issueThreadInteractions)
|
||||
.where(eq(issueThreadInteractions.id, interactionId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.status).toBe("pending");
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_confirmation accept when sourceRunId is null", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ sourceRunId: null });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{},
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_checkbox_confirmation accept when the source run finalized but a foreign run is mid-flight", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-b"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
result: {
|
||||
selectedOptionIds: ["file-b"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses request_checkbox_confirmation accept until the source run records a successful workspace_finalize", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, sourceRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation" });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining(
|
||||
"the run that created this interaction has not finished syncing its workspace",
|
||||
),
|
||||
details: { executionWorkspaceId, sourceRunId },
|
||||
});
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: sourceRunId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows request_checkbox_confirmation accept when sourceRunId is null", async () => {
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, interactionId, foreignRunId } =
|
||||
await seedAcceptGateFixture({ kind: "request_checkbox_confirmation", sourceRunId: null });
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
});
|
||||
|
||||
const accepted = await interactionsSvc.acceptInteraction(
|
||||
{ id: issueId, companyId, goalId, projectId: null },
|
||||
interactionId,
|
||||
{ selectedOptionIds: ["file-a"] },
|
||||
{ userId: "local-board" },
|
||||
);
|
||||
|
||||
expect(accepted.interaction).toMatchObject({
|
||||
id: interactionId,
|
||||
kind: "request_checkbox_confirmation",
|
||||
status: "accepted",
|
||||
});
|
||||
});
|
||||
@@ -1528,11 +1730,12 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
|
||||
// workspace_finalize gate (PAPA-440) must not apply here. Without this
|
||||
// carve-out the board cannot triage suggested tasks on an issue whose
|
||||
// latest workspace op is still worktree_prepare.
|
||||
const { companyId, executionWorkspaceId, issueId, goalId } = await seedAcceptGateFixture();
|
||||
const { companyId, executionWorkspaceId, issueId, goalId, foreignRunId } = await seedAcceptGateFixture();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
heartbeatRunId: foreignRunId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-28T22:00:00.000Z"),
|
||||
|
||||
@@ -2924,6 +2924,102 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedSharedWorkspaceDependency() {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
const foreignIssueId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
});
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
{
|
||||
id: foreignIssueId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Foreign in-flight issue",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
|
||||
return {
|
||||
companyId,
|
||||
assigneeAgentId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
foreignIssueId,
|
||||
};
|
||||
}
|
||||
|
||||
it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
@@ -3081,86 +3177,82 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gates dependents on the workspace-finalize barrier when a done blocker's execution workspace has not synced back", async () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const projectWorkspaceId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
it("treats done blockers on a shared workspace as ready while a foreign issue is in-flight", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
foreignIssueId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: assigneeAgentId,
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
name: "QA",
|
||||
role: "qa",
|
||||
status: "active",
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Shared workspace project",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: projectWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Shared workspace",
|
||||
sourceType: "local_path",
|
||||
visibility: "default",
|
||||
isPrimary: true,
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Shared exec workspace",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
executionWorkspaceId,
|
||||
issueId: foreignIssueId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
const blockerId = randomUUID();
|
||||
const dependentId = randomUUID();
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockerId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Predecessor",
|
||||
status: "done",
|
||||
priority: "medium",
|
||||
executionWorkspaceId,
|
||||
},
|
||||
{
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
companyId,
|
||||
projectId,
|
||||
title: "Dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
assigneeAgentId,
|
||||
},
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await svc.update(dependentId, { blockedByIssueIds: [blockerId] });
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
// A run touched the workspace (prepare phase) but has not yet recorded
|
||||
it("ignores unattributed pre-backfill workspace operations when checking blocker readiness", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: null,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
});
|
||||
|
||||
await expect(svc.listWakeableBlockedDependents(blockerId)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: dependentId,
|
||||
blockerIssueIds: [blockerId],
|
||||
}),
|
||||
]);
|
||||
await expect(svc.getDependencyReadiness(dependentId)).resolves.toMatchObject({
|
||||
isDependencyReady: true,
|
||||
pendingFinalizeBlockerIssueIds: [],
|
||||
unresolvedBlockerIssueIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("gates dependents on the blocker's own workspace-finalize barrier until sync-back succeeds", async () => {
|
||||
const {
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
blockerId,
|
||||
dependentId,
|
||||
assigneeAgentId,
|
||||
} = await seedSharedWorkspaceDependency();
|
||||
|
||||
// The blocker touched its workspace but has not yet recorded
|
||||
// workspace_finalize — the dependent must NOT wake.
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "worktree_prepare",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:00:00.000Z"),
|
||||
@@ -3177,6 +3269,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "workspace_finalize",
|
||||
status: "failed",
|
||||
startedAt: new Date("2026-05-23T22:05:00.000Z"),
|
||||
@@ -3188,6 +3281,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
|
||||
await db.insert(workspaceOperations).values({
|
||||
companyId,
|
||||
executionWorkspaceId,
|
||||
issueId: blockerId,
|
||||
phase: "workspace_finalize",
|
||||
status: "succeeded",
|
||||
startedAt: new Date("2026-05-23T22:10:00.000Z"),
|
||||
|
||||
@@ -3439,7 +3439,8 @@ export function agentRoutes(
|
||||
const agentId = req.query.agentId as string | undefined;
|
||||
const limitParam = req.query.limit as string | undefined;
|
||||
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
|
||||
const runs = await heartbeat.list(companyId, agentId, limit);
|
||||
const summary = req.query.summary === "true" || req.query.summary === "1";
|
||||
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
|
||||
res.json(runs);
|
||||
});
|
||||
|
||||
|
||||
@@ -3906,6 +3906,7 @@ export function issueRoutes(
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
@@ -3990,6 +3991,7 @@ export function issueRoutes(
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: keyParsed.data,
|
||||
documentKey: keyParsed.data,
|
||||
threadId: comment.threadId,
|
||||
commentId: comment.id,
|
||||
@@ -4043,6 +4045,7 @@ export function issueRoutes(
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: thread.documentKey,
|
||||
documentKey: thread.documentKey,
|
||||
documentId: thread.documentId,
|
||||
threadId: thread.id,
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
suggestTasksResultSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { issueService, listUnfinalizedExecutionWorkspaceIds } from "./issues.js";
|
||||
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
|
||||
|
||||
type InteractionActor = {
|
||||
agentId?: string | null;
|
||||
@@ -541,7 +541,10 @@ export function issueThreadInteractionService(db: Db) {
|
||||
async function assertIssueWorkspaceFinalizedForAccept(args: {
|
||||
db: Pick<Db, "select">;
|
||||
issue: { id: string; companyId: string };
|
||||
sourceRunId: string | null;
|
||||
}) {
|
||||
if (!args.sourceRunId) return;
|
||||
|
||||
const executionWorkspaceId = await args.db
|
||||
.select({ executionWorkspaceId: issues.executionWorkspaceId })
|
||||
.from(issues)
|
||||
@@ -550,17 +553,18 @@ export function issueThreadInteractionService(db: Db) {
|
||||
|
||||
if (!executionWorkspaceId) return;
|
||||
|
||||
const unfinalized = await listUnfinalizedExecutionWorkspaceIds(
|
||||
const isFinalized = await runWorkspaceIsFinalized(
|
||||
args.db,
|
||||
args.issue.companyId,
|
||||
[executionWorkspaceId],
|
||||
executionWorkspaceId,
|
||||
args.sourceRunId,
|
||||
);
|
||||
if (!unfinalized.has(executionWorkspaceId)) return;
|
||||
if (isFinalized) return;
|
||||
|
||||
throw conflict(
|
||||
"Cannot accept interaction: the issue's most recent run has not completed workspace_finalize. "
|
||||
"Cannot accept interaction: the run that created this interaction has not finished syncing its workspace. "
|
||||
+ "Retry once the local worktree has finished syncing.",
|
||||
{ executionWorkspaceId },
|
||||
{ executionWorkspaceId, sourceRunId: args.sourceRunId },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -870,7 +874,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
// workspace_finalize gate (PAPA-440) does not apply here.
|
||||
return issueThreadInteractionService(db).acceptSuggestedTasks(issue, interactionId, data, actor);
|
||||
case "request_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
@@ -884,7 +888,7 @@ export function issueThreadInteractionService(db: Db) {
|
||||
};
|
||||
}
|
||||
case "request_checkbox_confirmation": {
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
|
||||
await assertIssueWorkspaceFinalizedForAccept({ db, issue, sourceRunId: current.sourceRunId });
|
||||
const accepted = await acceptRequestConfirmation({
|
||||
issue,
|
||||
current,
|
||||
|
||||
@@ -689,6 +689,94 @@ export async function listUnfinalizedExecutionWorkspaceIds(
|
||||
return unfinalized;
|
||||
}
|
||||
|
||||
async function listPendingFinalizeBlockerIssueIds(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
blockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }>,
|
||||
): Promise<Set<string>> {
|
||||
const pending = new Set<string>();
|
||||
const blockerIssueIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.blockerIssueId))];
|
||||
const executionWorkspaceIds = [...new Set(blockerWorkspacePairs.map((pair) => pair.executionWorkspaceId))];
|
||||
if (blockerIssueIds.length === 0 || executionWorkspaceIds.length === 0) return pending;
|
||||
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
issueId: workspaceOperations.issueId,
|
||||
executionWorkspaceId: workspaceOperations.executionWorkspaceId,
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
inArray(workspaceOperations.issueId, blockerIssueIds),
|
||||
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
|
||||
),
|
||||
);
|
||||
|
||||
const latestByBlockerWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
|
||||
for (const row of rows) {
|
||||
if (!row.issueId || !row.executionWorkspaceId) continue;
|
||||
const key = `${row.issueId}:${row.executionWorkspaceId}`;
|
||||
const current = latestByBlockerWorkspace.get(key);
|
||||
if (!current || row.startedAt > current.startedAt) {
|
||||
latestByBlockerWorkspace.set(key, {
|
||||
phase: row.phase,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const pair of blockerWorkspacePairs) {
|
||||
const latest = latestByBlockerWorkspace.get(`${pair.blockerIssueId}:${pair.executionWorkspaceId}`);
|
||||
if (!latest) continue; // no attributed ops recorded -> nothing to finalize for this blocker
|
||||
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
|
||||
pending.add(pair.blockerIssueId);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a specific run's operations on a specific execution workspace
|
||||
* reached the workspace_finalize barrier.
|
||||
*
|
||||
* Runs with no operations on the workspace are considered finalized because
|
||||
* they never touched the workspace state that accept/review gates protect.
|
||||
*/
|
||||
export async function runWorkspaceIsFinalized(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
executionWorkspaceId: string,
|
||||
runId: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
phase: workspaceOperations.phase,
|
||||
status: workspaceOperations.status,
|
||||
startedAt: workspaceOperations.startedAt,
|
||||
})
|
||||
.from(workspaceOperations)
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceOperations.companyId, companyId),
|
||||
eq(workspaceOperations.executionWorkspaceId, executionWorkspaceId),
|
||||
eq(workspaceOperations.heartbeatRunId, runId),
|
||||
),
|
||||
);
|
||||
|
||||
let latest: { phase: string; status: string; startedAt: Date } | null = null;
|
||||
for (const row of rows) {
|
||||
if (!latest || row.startedAt > latest.startedAt) latest = row;
|
||||
}
|
||||
|
||||
if (!latest) return true;
|
||||
return latest.phase === "workspace_finalize" && latest.status === "succeeded";
|
||||
}
|
||||
|
||||
async function listIssueDependencyReadinessMap(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
companyId: string,
|
||||
@@ -718,19 +806,22 @@ async function listIssueDependencyReadinessMap(
|
||||
),
|
||||
);
|
||||
|
||||
// Collect executionWorkspaceIds of "done" blockers — these are the only ones
|
||||
// Collect issue/workspace pairs of "done" blockers — these are the only ones
|
||||
// subject to the workspace-finalize barrier. Blockers that aren't done already
|
||||
// mark the dependent as not-ready and don't need a finalize check.
|
||||
const doneBlockerWorkspaceIds = new Set<string>();
|
||||
const doneBlockerWorkspacePairs: Array<{ blockerIssueId: string; executionWorkspaceId: string }> = [];
|
||||
for (const row of blockerRows) {
|
||||
if (row.blockerStatus === "done" && row.blockerExecutionWorkspaceId) {
|
||||
doneBlockerWorkspaceIds.add(row.blockerExecutionWorkspaceId);
|
||||
doneBlockerWorkspacePairs.push({
|
||||
blockerIssueId: row.blockerIssueId,
|
||||
executionWorkspaceId: row.blockerExecutionWorkspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unfinalizedWorkspaceIds = await listUnfinalizedExecutionWorkspaceIds(
|
||||
const pendingFinalizeBlockerIssueIds = await listPendingFinalizeBlockerIssueIds(
|
||||
dbOrTx,
|
||||
companyId,
|
||||
[...doneBlockerWorkspaceIds],
|
||||
doneBlockerWorkspacePairs,
|
||||
);
|
||||
|
||||
for (const row of blockerRows) {
|
||||
@@ -745,7 +836,7 @@ async function listIssueDependencyReadinessMap(
|
||||
current.isDependencyReady = false;
|
||||
} else if (
|
||||
row.blockerExecutionWorkspaceId &&
|
||||
unfinalizedWorkspaceIds.has(row.blockerExecutionWorkspaceId)
|
||||
pendingFinalizeBlockerIssueIds.has(row.blockerIssueId)
|
||||
) {
|
||||
// Workspace-finalize barrier: the blocker's most recent run on its
|
||||
// execution workspace hasn't recorded a successful workspace_finalize.
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
VIRTUALIZED_THREAD_ROW_THRESHOLD,
|
||||
canStopIssueChatRun,
|
||||
findLatestCommentMessageIndex,
|
||||
getVirtualizedMeasurementScrollAdjustment,
|
||||
resolveAssistantMessageFoldedState,
|
||||
resolveIssueChatHumanAuthor,
|
||||
} from "./IssueChatThread";
|
||||
@@ -674,6 +675,51 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("cycles work modes and prevents default when iOS leaves the keydown code empty", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
issueWorkMode="standard"
|
||||
onAdd={async () => {}}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null;
|
||||
expect(composer).not.toBeNull();
|
||||
expect(composer?.getAttribute("data-pending-work-mode")).toBe("standard");
|
||||
|
||||
// iOS Safari with a hardware keyboard frequently reports an empty `code` for
|
||||
// cmd-period. Without a `key` fallback the handler returns early, never calls
|
||||
// preventDefault, and Safari's default cancel/dismiss closes the view.
|
||||
const evt = new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: "",
|
||||
key: ".",
|
||||
metaKey: true,
|
||||
});
|
||||
act(() => {
|
||||
composer?.dispatchEvent(evt);
|
||||
});
|
||||
|
||||
expect(evt.defaultPrevented).toBe(true);
|
||||
expect(composer?.getAttribute("data-pending-work-mode")).not.toBe("standard");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("virtualizes long merged threads so only a windowed slice mounts", () => {
|
||||
const root = createRoot(container);
|
||||
const totalMergedRows =
|
||||
@@ -1343,6 +1389,31 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the viewport anchored when virtualized rows above it remeasure", () => {
|
||||
expect(getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: 200,
|
||||
previousSize: 220,
|
||||
nextSize: 360,
|
||||
viewportStart: 480,
|
||||
})).toBe(140);
|
||||
|
||||
expect(getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: 200,
|
||||
previousSize: 360,
|
||||
nextSize: 180,
|
||||
viewportStart: 620,
|
||||
})).toBe(-180);
|
||||
});
|
||||
|
||||
it("does not scroll-anchor virtualized measurement changes inside the viewport", () => {
|
||||
expect(getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: 420,
|
||||
previousSize: 220,
|
||||
nextSize: 360,
|
||||
viewportStart: 480,
|
||||
})).toBe(0);
|
||||
});
|
||||
|
||||
it("renders virtualized rows with the same role/kind metadata as the direct path", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import {
|
||||
buildIssueChatMessages,
|
||||
formatDurationWords,
|
||||
isCoTSegmentActive,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
@@ -870,13 +871,16 @@ function IssueChatChainOfThought({
|
||||
(p): p is ToolCallMessagePart => p.type === "tool-call",
|
||||
);
|
||||
|
||||
const isActive = isMessageRunning;
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
|
||||
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
|
||||
? (custom.chainOfThoughtSegments as SegmentTiming[])
|
||||
: [];
|
||||
const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null;
|
||||
const isActive = isCoTSegmentActive({
|
||||
isMessageRunning,
|
||||
segmentIndex: myIndex,
|
||||
segmentCount: rawSegments.length,
|
||||
});
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2941,6 +2945,18 @@ type SimpleVirtualItem = {
|
||||
size: number;
|
||||
};
|
||||
|
||||
export function getVirtualizedMeasurementScrollAdjustment(args: {
|
||||
itemStart: number;
|
||||
previousSize: number;
|
||||
nextSize: number;
|
||||
viewportStart: number;
|
||||
}) {
|
||||
const { itemStart, previousSize, nextSize, viewportStart } = args;
|
||||
const previousEnd = itemStart + previousSize;
|
||||
if (previousEnd > viewportStart) return 0;
|
||||
return nextSize - previousSize;
|
||||
}
|
||||
|
||||
function useIssueThreadVirtualizer({
|
||||
count,
|
||||
estimateSize,
|
||||
@@ -3055,7 +3071,20 @@ function useIssueThreadVirtualizer({
|
||||
const key = getItemKey(index);
|
||||
const previousSize = measuredSizeByKeyRef.current.get(key) ?? estimatedSize;
|
||||
if (Math.abs(previousSize - measuredSize) < 1) return;
|
||||
const scrollAdjustment = getVirtualizedMeasurementScrollAdjustment({
|
||||
itemStart: itemStarts[index] ?? scrollMargin,
|
||||
previousSize,
|
||||
nextSize: measuredSize,
|
||||
viewportStart: Math.max(scrollMargin, scrollOffset()),
|
||||
});
|
||||
measuredSizeByKeyRef.current.set(key, measuredSize);
|
||||
if (Math.abs(scrollAdjustment) >= 1) {
|
||||
if (mode.kind === "window") {
|
||||
window.scrollBy({ top: scrollAdjustment, behavior: "auto" });
|
||||
} else {
|
||||
mode.element.scrollBy({ top: scrollAdjustment, behavior: "auto" });
|
||||
}
|
||||
}
|
||||
rerender((value) => value + 1);
|
||||
},
|
||||
};
|
||||
@@ -3746,7 +3775,13 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
|
||||
|
||||
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
|
||||
// Match the period via both `code` and `key`: iOS Safari with a hardware
|
||||
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
|
||||
// lets the event fall through and triggers Safari's default cancel/dismiss
|
||||
// (which closes the view). Catching `key === "."` keeps the shortcut working
|
||||
// on iOS while preserving desktop behavior.
|
||||
const isPeriod = evt.code === "Period" || evt.key === ".";
|
||||
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
|
||||
evt.preventDefault();
|
||||
setPendingWorkMode((current) => nextWorkMode(current, true));
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import { copyTextToClipboard } from "../lib/clipboard";
|
||||
import {
|
||||
buildIssueChatMessages,
|
||||
formatDurationWords,
|
||||
isCoTSegmentActive,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
@@ -849,13 +850,16 @@ function IssueChatChainOfThought({
|
||||
(p): p is ToolCallMessagePart => p.type === "tool-call",
|
||||
);
|
||||
|
||||
const isActive = isMessageRunning;
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
|
||||
const rawSegments = Array.isArray(custom.chainOfThoughtSegments)
|
||||
? (custom.chainOfThoughtSegments as SegmentTiming[])
|
||||
: [];
|
||||
const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null;
|
||||
const isActive = isCoTSegmentActive({
|
||||
isMessageRunning,
|
||||
segmentIndex: myIndex,
|
||||
segmentCount: rawSegments.length,
|
||||
});
|
||||
const [expanded, setExpanded] = useState(isActive);
|
||||
const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3587,7 +3591,13 @@ const IssueChatComposer = forwardRef<IssueChatComposerHandle, IssueChatComposerP
|
||||
const PendingWorkModeIcon = pendingWorkModeMeta.icon;
|
||||
|
||||
function handleComposerKeyDown(evt: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (!(evt.metaKey || evt.ctrlKey) || evt.code !== "Period") return;
|
||||
// Match the period via both `code` and `key`: iOS Safari with a hardware
|
||||
// keyboard often leaves `code` empty for cmd-period, so relying on it alone
|
||||
// lets the event fall through and triggers Safari's default cancel/dismiss
|
||||
// (which closes the view). Catching `key === "."` keeps the shortcut working
|
||||
// on iOS while preserving desktop behavior.
|
||||
const isPeriod = evt.code === "Period" || evt.key === ".";
|
||||
if (!(evt.metaKey || evt.ctrlKey) || !isPeriod) return;
|
||||
evt.preventDefault();
|
||||
setPendingWorkMode((current) => nextWorkMode(current, false));
|
||||
}
|
||||
|
||||
@@ -139,6 +139,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.documentRevisions("issue-1", "plan"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.documents("PAP-9403"),
|
||||
});
|
||||
@@ -148,6 +151,9 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.documentRevisions("PAP-9403", "plan"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.documents("issue-1"),
|
||||
refetchType: "inactive",
|
||||
@@ -186,6 +192,83 @@ describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-revisions", "issue-1"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "issue-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes document annotation caches when annotation activity arrives", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
action: "issue.document_annotation_comment_added",
|
||||
actorType: "user",
|
||||
actorId: "user-2",
|
||||
details: {
|
||||
identifier: "PAP-9403",
|
||||
documentKey: "plan",
|
||||
threadId: "thread-1",
|
||||
commentId: "comment-1",
|
||||
},
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "issue-1", "plan"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["issues", "document-annotations", "PAP-9403", "plan"],
|
||||
});
|
||||
expect(invalidations).not.toContainEqual({
|
||||
queryKey: queryKeys.issues.documents("issue-1"),
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes routine description annotation caches when routine annotation activity arrives", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "routine",
|
||||
entityId: "routine-1",
|
||||
action: "routine.document_annotation_comment_added",
|
||||
actorType: "user",
|
||||
actorId: "user-2",
|
||||
details: {
|
||||
documentKey: "description",
|
||||
threadId: "thread-1",
|
||||
commentId: "comment-1",
|
||||
},
|
||||
},
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["routines"],
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: ["routines", "document-annotations", "routine-1", "description"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps self-authored comment events from refetching the active issue tree", () => {
|
||||
|
||||
@@ -415,6 +415,20 @@ const ISSUE_DOCUMENT_ACTIVITY_ACTIONS = new Set([
|
||||
"issue.document_restored",
|
||||
"issue.document_deleted",
|
||||
]);
|
||||
const ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
|
||||
"issue.document_annotation_thread_created",
|
||||
"issue.document_annotation_comment_added",
|
||||
"issue.document_annotation_thread_resolved",
|
||||
"issue.document_annotation_thread_reopened",
|
||||
"issue.document_annotation_remapped",
|
||||
]);
|
||||
const ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS = new Set([
|
||||
"routine.document_annotation_thread_created",
|
||||
"routine.document_annotation_comment_added",
|
||||
"routine.document_annotation_thread_resolved",
|
||||
"routine.document_annotation_thread_reopened",
|
||||
"routine.document_annotation_remapped",
|
||||
]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["error"]);
|
||||
const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]);
|
||||
|
||||
@@ -710,6 +724,18 @@ function invalidateActivityQueries(
|
||||
queryClient.invalidateQueries({ queryKey: ["issues", "document-revisions", ref], ...invalidationOptions });
|
||||
}
|
||||
}
|
||||
if (
|
||||
action &&
|
||||
(ISSUE_DOCUMENT_ACTIVITY_ACTIONS.has(action) || ISSUE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action))
|
||||
) {
|
||||
const documentKey = readString(details?.key) ?? readString(details?.documentKey);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: documentKey
|
||||
? ["issues", "document-annotations", ref, documentKey]
|
||||
: ["issues", "document-annotations", ref],
|
||||
...invalidationOptions,
|
||||
});
|
||||
}
|
||||
if (action?.startsWith("issue.thread_interaction_")) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), ...invalidationOptions });
|
||||
}
|
||||
@@ -761,6 +787,12 @@ function invalidateActivityQueries(
|
||||
|
||||
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
|
||||
queryClient.invalidateQueries({ queryKey: ["routines"] });
|
||||
if (entityType === "routine" && action && ROUTINE_DOCUMENT_ANNOTATION_ACTIVITY_ACTIONS.has(action) && entityId) {
|
||||
const documentKey = readString(details?.key) ?? readString(details?.documentKey) ?? "description";
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["routines", "document-annotations", entityId, documentKey],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
buildAssistantPartsFromTranscript,
|
||||
buildIssueChatMessages,
|
||||
isCoTSegmentActive,
|
||||
stabilizeThreadMessages,
|
||||
type IssueChatComment,
|
||||
type IssueChatLinkedRun,
|
||||
@@ -238,6 +239,24 @@ describe("buildAssistantPartsFromTranscript", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it("marks only the latest chain-of-thought segment active while a run is live", () => {
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 0,
|
||||
segmentCount: 2,
|
||||
})).toBe(false);
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: true,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
})).toBe(true);
|
||||
expect(isCoTSegmentActive({
|
||||
isMessageRunning: false,
|
||||
segmentIndex: 1,
|
||||
segmentCount: 2,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps run errors while suppressing init and system transcript noise", () => {
|
||||
const result = buildAssistantPartsFromTranscript([
|
||||
{
|
||||
|
||||
@@ -538,6 +538,17 @@ export interface SegmentTiming {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export function isCoTSegmentActive(args: {
|
||||
isMessageRunning: boolean;
|
||||
segmentIndex: number;
|
||||
segmentCount: number;
|
||||
}) {
|
||||
const { isMessageRunning, segmentIndex, segmentCount } = args;
|
||||
if (!isMessageRunning) return false;
|
||||
if (segmentCount <= 0 || segmentIndex < 0) return true;
|
||||
return segmentIndex === segmentCount - 1;
|
||||
}
|
||||
|
||||
function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): SegmentTiming[] {
|
||||
const timings: SegmentTiming[] = [];
|
||||
let inSegment = false;
|
||||
|
||||
Reference in new issue
Block a user