Skip to content

Commit cd92904

Browse files
committed
PRs tab fixes: state-aware badges, expandable issue rows, composer pending-input lock
- prs: lane-scoped PR display falls back to merged/closed PRs; state-aware badge labels (`formatPrBadgeLabel`) - prs: PrConvergencePanel issue rows are expandable with author/body/thread comment count, "show ignored" toggle, "Ignore comment" rename, manual-run round labels - prs: dedupe Actions check entries; ignore/expand review items - lanes: state-aware lane-list PR tag via `selectLanePrTag` - chat: hard-lock composer + server-side gate while `pendingInput.blocking` is set; merged-PR display in PrDetailPane - ios: WorkChatSessionView updates to match new chat lock contract - docs: refresh chat/composer-and-ui, lanes/README, pull-requests/README
1 parent 812254b commit cd92904

21 files changed

Lines changed: 956 additions & 87 deletions

apps/ade-cli/package-lock.json

Lines changed: 3 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/main/services/chat/agentChatService.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9670,6 +9670,56 @@ describe("createAgentChatService", () => {
96709670
expect(readPersistedChatState(session.id).awaitingInput).toBeUndefined();
96719671
});
96729672

9673+
it("rejects normal chat sends while a pending input request is waiting", async () => {
9674+
const events: AgentChatEventEnvelope[] = [];
9675+
const { service } = createService({
9676+
onEvent: (event: AgentChatEventEnvelope) => events.push(event),
9677+
});
9678+
9679+
const session = await service.createSession({
9680+
laneId: "lane-1",
9681+
provider: "codex",
9682+
model: "gpt-5.4",
9683+
});
9684+
9685+
const requestPromise = service.requestChatInput({
9686+
chatSessionId: session.id,
9687+
title: "Pending question",
9688+
body: "Which path should we take?",
9689+
questions: [{
9690+
id: "answer",
9691+
header: "Question 1",
9692+
question: "Which path should we take?",
9693+
allowsFreeform: true,
9694+
}],
9695+
});
9696+
9697+
const approvalEvent = await waitForEvent(
9698+
events,
9699+
(event): event is AgentChatEventEnvelope & {
9700+
event: Extract<AgentChatEventEnvelope["event"], { type: "approval_request" }>;
9701+
} => {
9702+
const detail = event.event.type === "approval_request"
9703+
? (event.event.detail as { request?: { title?: string } } | undefined)
9704+
: undefined;
9705+
return event.event.type === "approval_request" && detail?.request?.title === "Pending question";
9706+
},
9707+
);
9708+
9709+
await expect(service.sendMessage({
9710+
sessionId: session.id,
9711+
text: "Treat this as the answer even though it came through chat.send.",
9712+
})).rejects.toThrow("Answer or decline the pending request before sending another message.");
9713+
9714+
await service.respondToInput({
9715+
sessionId: session.id,
9716+
itemId: approvalEvent.event.itemId,
9717+
decision: "decline",
9718+
});
9719+
9720+
await expect(requestPromise).resolves.toMatchObject({ decision: "decline" });
9721+
});
9722+
96739723
it("maps freeform replies to the single pending question when only one answer is needed", async () => {
96749724
const events: AgentChatEventEnvelope[] = [];
96759725
const { service } = createService({

apps/desktop/src/main/services/chat/agentChatService.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,9 +855,12 @@ function normalizeCodexAssistantDelta(
855855
return args.delta;
856856
}
857857

858+
const PENDING_INPUT_SEND_BLOCKED_MESSAGE = "Answer or decline the pending request before sending another message.";
859+
858860
function validateSessionReadyForTurn(managed: ManagedChatSession): { ready: true } | { ready: false; reason: string } {
859861
if (managed.closed) return { ready: false, reason: "Session is disposed" };
860862
if (!managed.runtime) return { ready: false, reason: "No runtime initialized" };
863+
if (hasLivePendingInput(managed)) return { ready: false, reason: PENDING_INPUT_SEND_BLOCKED_MESSAGE };
861864
const rt = managed.runtime;
862865
if ((rt.kind === "opencode" || rt.kind === "claude" || rt.kind === "cursor" || rt.kind === "droid") && rt.busy) {
863866
return { ready: false, reason: "Turn already active" };
@@ -12266,6 +12269,9 @@ export function createAgentChatService(args: {
1226612269
const visibleText = displayText?.trim().length ? displayText.trim() : trimmed;
1226712270

1226812271
const managed = ensureManagedSession(sessionId);
12272+
if (hasLivePendingInput(managed)) {
12273+
throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE);
12274+
}
1226912275
const executionContext = refreshManagedLaneLaunchContext(managed);
1227012276
const publicAttachments = attachments.map((attachment) => ({
1227112277
...attachment,
@@ -15573,6 +15579,9 @@ export function createAgentChatService(args: {
1557315579
}
1557415580

1557515581
const managed = ensureManagedSession(sessionId);
15582+
if (hasLivePendingInput(managed)) {
15583+
throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE);
15584+
}
1557615585

1557715586
// OpenCode runtime steer
1557815587
if (managed.runtime?.kind === "opencode") {
@@ -15856,6 +15865,9 @@ export function createAgentChatService(args: {
1585615865
if (managed.session.provider === "codex") {
1585715866
throw new Error("dispatchSteer is not supported on Codex sessions.");
1585815867
}
15868+
if (hasLivePendingInput(managed)) {
15869+
throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE);
15870+
}
1585915871
const runtime = managed.runtime;
1586015872
if (!runtime) return { dispatchedAt: null };
1586115873
if (runtime.kind !== "claude") {

apps/desktop/src/main/services/prs/prService.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ describe("prService.getForLane", () => {
277277
expect(service.getForLane(lane.id)?.githubPrNumber).toBe(93);
278278
});
279279

280-
it("ignores terminal PR rows when resolving the current lane PR", () => {
280+
it("surfaces a merged PR row when it still matches the current lane branch", () => {
281281
const lane = makeFakeLane({
282282
branchRef: "refs/heads/current-feature",
283283
});
@@ -289,6 +289,21 @@ describe("prService.getForLane", () => {
289289
}),
290290
]);
291291

292+
expect(service.getForLane(lane.id)?.state).toBe("merged");
293+
});
294+
295+
it("ignores terminal PR rows whose head branch no longer matches the lane branch", () => {
296+
const lane = makeFakeLane({
297+
branchRef: "refs/heads/current-feature",
298+
});
299+
const service = buildGetForLaneService(lane, [
300+
makePrRow({
301+
lane_id: lane.id,
302+
state: "merged",
303+
head_branch: "old-feature",
304+
}),
305+
]);
306+
292307
expect(service.getForLane(lane.id)).toBeNull();
293308
});
294309

apps/desktop/src/main/services/prs/prService.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -904,7 +904,10 @@ export function createPrService({
904904

905905
const rowMatchesCurrentLaneBranch = (row: PullRequestRow, lane: LanePrLookupRow): boolean => {
906906
if (!isActivePrState(row.state)) return false;
907+
return rowMatchesLaneBranchForDisplay(row, lane);
908+
};
907909

910+
const rowMatchesLaneBranchForDisplay = (row: PullRequestRow, lane: LanePrLookupRow): boolean => {
908911
const laneBranch = normalizeBranchName(branchNameFromRef(lane.branch_ref ?? ""));
909912
const prHeadBranch = normalizeBranchName(row.head_branch);
910913
if (!laneBranch || !prHeadBranch || laneBranch !== prHeadBranch) return false;
@@ -917,6 +920,30 @@ export function createPrService({
917920
return true;
918921
};
919922

923+
const getDisplayRowForCurrentLaneBranch = (laneId: string): PullRequestRow | null => {
924+
const lane = getLanePrLookupRow(laneId);
925+
if (!lane || lane.archived_at) return null;
926+
927+
const rows = db.all<PullRequestRow>(
928+
`
929+
select ${PR_COLUMNS}
930+
from pull_requests
931+
where lane_id = ?
932+
and project_id = ?
933+
order by
934+
case when state in ('open', 'draft') then 0 when state = 'merged' then 1 else 2 end,
935+
updated_at desc,
936+
created_at desc
937+
`,
938+
[laneId, projectId],
939+
);
940+
941+
const laneBranch = normalizeBranchName(branchNameFromRef(lane.branch_ref ?? ""));
942+
if (!laneBranch) return rows[0] ?? null;
943+
944+
return rows.find((row) => rowMatchesLaneBranchForDisplay(row, lane)) ?? null;
945+
};
946+
920947
const getActiveRowForCurrentLaneBranch = (laneId: string): PullRequestRow | null => {
921948
const lane = getLanePrLookupRow(laneId);
922949
if (!lane || lane.archived_at) return null;
@@ -5214,7 +5241,7 @@ export function createPrService({
52145241
},
52155242

52165243
getForLane(laneId: string): PrSummary | null {
5217-
const row = getActiveRowForCurrentLaneBranch(laneId);
5244+
const row = getDisplayRowForCurrentLaneBranch(laneId);
52185245
return row ? rowToSummary(row) : null;
52195246
},
52205247

apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,46 @@ describe("AgentChatComposer", () => {
303303
},
304304
});
305305

306-
expect(screen.getByText("Answer in the inline question card, or type below.")).toBeTruthy();
306+
expect(screen.getByText("Answer in the inline question card, or decline.")).toBeTruthy();
307307
expect(screen.queryByText("Answer in the inline question card, or pick an option there.")).toBeNull();
308308
});
309309

310+
it("locks the prompt box while a pending question is waiting", () => {
311+
const props = renderComposer({
312+
pendingInput: {
313+
requestId: "req-lock",
314+
itemId: "item-lock",
315+
source: "claude",
316+
kind: "question",
317+
title: "Input needed",
318+
description: "What should we do next?",
319+
questions: [{
320+
id: "answer",
321+
header: "Question 1",
322+
question: "What should we do next?",
323+
allowsFreeform: true,
324+
}],
325+
allowsFreeform: true,
326+
blocking: true,
327+
canProceedWithoutAnswer: false,
328+
turnId: null,
329+
},
330+
});
331+
332+
const textbox = screen.getByRole("textbox") as HTMLTextAreaElement;
333+
expect(textbox.disabled).toBe(true);
334+
expect(textbox.placeholder).toBe("Answer the question card above, or decline it.");
335+
expect(screen.queryByLabelText("Send steer message")).toBeNull();
336+
expect((screen.getByLabelText("Open attachment picker") as HTMLButtonElement).disabled).toBe(true);
337+
expect((screen.getByLabelText("Upload file from disk") as HTMLButtonElement).disabled).toBe(true);
338+
expect((screen.getByLabelText("Open command picker") as HTMLButtonElement).disabled).toBe(true);
339+
340+
fireEvent.keyDown(textbox, { key: "Enter" });
341+
342+
expect(props.onApproval).not.toHaveBeenCalled();
343+
expect(props.onSubmit).not.toHaveBeenCalled();
344+
});
345+
310346
it("keeps the option hint when a pending question includes selectable options", () => {
311347
renderComposer({
312348
pendingInput: {

0 commit comments

Comments
 (0)