Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/comment-pr-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ jobs:

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const maxAttempts = 60;
const pollIntervalMs = 20_000;

let matchedRun = null;
for (let attempt = 1; attempt <= 30; attempt += 1) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
Expand All @@ -78,8 +81,8 @@ jobs:
break;
}

core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/30)`);
await sleep(10000);
core.info(`Waiting for PR Build Validation run for ${headSha} (attempt ${attempt}/${maxAttempts})`);
await sleep(pollIntervalMs);
}

if (!matchedRun) {
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- ready_for_review
Expand Down
15 changes: 13 additions & 2 deletions packages/ui/src/components/tool-call.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../lib/i18n"
import { resolveToolRenderer } from "./tool-call/renderers"
import { QuestionToolBlock } from "./tool-call/question-block"
import { isInlineQuestionActive } from "./tool-call/question-active"
import { PermissionToolBlock } from "./tool-call/permission-block"
import { createAnsiContentRenderer } from "./tool-call/ansi-render"
import { createDiffContentRenderer } from "./tool-call/diff-render"
Expand Down Expand Up @@ -651,11 +652,21 @@ export default function ToolCall(props: ToolCallProps) {
return active?.kind === "permission" && active.id === pending.permission.id
})

// Task 059: the inline question block must derive its "active" state from the
// v2 message store only. The legacy `activeInterruption` signal is kept for
// cross-cutting consumers (permission modal, banner) but no longer gates the
// inline prompt — that split was the root cause of the "options render but
// are unclickable" bug. The rule mirrors the order used by the permission
// approval modal: a question is interactive iff it is the head of the v2
// question queue AND no permission interruption is ahead in the v2 store.
const isQuestionActive = createMemo(() => {
const pending = pendingQuestion()
if (!pending?.request) return false
const active = activeRequest()
return active?.kind === "question" && active.id === pending.request.id
return isInlineQuestionActive({
requestId: pending.request.id,
questionsActiveRequestId: store().state.questions.active?.request.id ?? null,
permissionsActiveId: store().state.permissions.active?.permission.id ?? null,
})
})

const expanded = () => {
Expand Down
90 changes: 90 additions & 0 deletions packages/ui/src/components/tool-call/question-active.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"

import { createInstanceMessageStore } from "../../stores/message-v2/instance-store.ts"
import { isInlineQuestionActive } from "./question-active.ts"

describe("isInlineQuestionActive (task 059)", () => {
it("returns true when the question is the head of the v2 question queue and no permission is ahead", () => {
const store = createInstanceMessageStore("instance-1")
store.upsertQuestion({
request: { id: "question-1", questions: [{ header: "Pick", question: "?", options: [{ label: "A", description: "" }] }] } as any,
messageId: "msg-1",
partId: "part-1",
enqueuedAt: 1_000,
})

const result = isInlineQuestionActive({
requestId: "question-1",
questionsActiveRequestId: store.state.questions.active?.request.id ?? null,
permissionsActiveId: store.state.permissions.active?.permission.id ?? null,
})

assert.equal(result, true)
})

it("returns false when a permission interruption is ahead of the question (F-5 / F-1 reproduction)", () => {
const store = createInstanceMessageStore("instance-1")

// Permission lands first and takes the v2 active slot.
store.upsertPermission({
permission: { id: "permission-1", time: { created: 1_000 } } as any,
messageId: "msg-1",
partId: "perm-part-1",
enqueuedAt: 1_000,
})

// Then a question arrives — its options will render but the inline block
// must not be interactive because a permission is ahead.
store.upsertQuestion({
request: { id: "question-1", questions: [{ header: "Pick", question: "?", options: [{ label: "A", description: "" }] }] } as any,
messageId: "msg-1",
partId: "tool-part-1",
enqueuedAt: 2_000,
})

const result = isInlineQuestionActive({
requestId: "question-1",
questionsActiveRequestId: store.state.questions.active?.request.id ?? null,
permissionsActiveId: store.state.permissions.active?.permission.id ?? null,
})

assert.equal(result, false, "Question prompt must be inactive while a permission is ahead in the queue")
})

it("returns false when another question is ahead in the queue", () => {
const store = createInstanceMessageStore("instance-1")
store.upsertQuestion({
request: { id: "question-1", questions: [] } as any,
messageId: "msg-1",
partId: "part-1",
enqueuedAt: 1_000,
})
store.upsertQuestion({
request: { id: "question-2", questions: [] } as any,
messageId: "msg-1",
partId: "part-2",
enqueuedAt: 2_000,
})

// The store keeps the first inserted entry as active.
const activeId = store.state.questions.active?.request.id
assert.equal(activeId, "question-1")

const queuedResult = isInlineQuestionActive({
requestId: "question-2",
questionsActiveRequestId: activeId ?? null,
permissionsActiveId: null,
})
assert.equal(queuedResult, false)
})

it("returns false when the request id is missing", () => {
const result = isInlineQuestionActive({
requestId: undefined,
questionsActiveRequestId: "question-1",
permissionsActiveId: null,
})
assert.equal(result, false)
})
})
32 changes: 32 additions & 0 deletions packages/ui/src/components/tool-call/question-active.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Decide whether an inline question prompt should be interactive.
*
* The legacy `activeInterruption` signal in `instances.ts` and the v2 store's
* `state.questions.active` field used to disagree about which question owned
* the focus, which produced the "options render but cannot be clicked" bug
* tracked in tasks 058 / 059.
*
* This helper now derives the answer from the v2 store only:
* - The question must be the head of the v2 question queue
* (`questionsActiveRequestId === request.id`).
* - No permission interruption may be ahead of the question
* (`permissionsActiveId == null`).
*
* Keeping the rule pure makes it cheap to unit test and removes any
* dependency on the legacy `activeInterruption` signal for the inline
* `<QuestionToolBlock>`.
*/
export interface QuestionActiveInput {
/** Question request id rendered by the inline block. */
requestId: string | null | undefined
/** `state.questions.active?.request.id` from the v2 message store. */
questionsActiveRequestId: string | null | undefined
/** `state.permissions.active?.permission.id` from the v2 message store. */
permissionsActiveId: string | null | undefined
}

export function isInlineQuestionActive(input: QuestionActiveInput): boolean {
if (!input.requestId) return false
if (input.permissionsActiveId) return false
return input.questionsActiveRequestId === input.requestId
}
40 changes: 37 additions & 3 deletions packages/ui/src/components/tool-call/question-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,39 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
updateAnswer(questionIndex, next)
}

// Task 059: when the question is pending but not active (e.g. a permission
// interruption is ahead of it, or another question is currently active),
// render an explicit "queued" state instead of a fully-rendered but
// unclickable radio list with no Submit button. This makes the inactive
// state self-explanatory and removes the false impression that the system
// is still loading.
const isQueuedBehindInterruption = createMemo(() => {
if (props.active()) return false
if (hasFinalAnswers()) return false
return Boolean(props.request())
})

return (
<Show when={isVisible() && questions().length > 0}>
<div
class={`tool-call-permission p-0 gap-2 ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"} ${hasFinalAnswers() ? "tool-call-permission-answered" : ""}`}
>
<Show when={isQueuedBehindInterruption()}>
<div class="tool-call-permission-body">
<div
class="tool-call-permission-queued-state border border-base bg-surface-secondary p-3 text-primary"
role="status"
aria-live="polite"
data-testid="question-queued-state"
>
<div class="text-sm font-semibold text-primary">{t("toolCall.question.queuedLabel")}</div>
<p class="tool-call-permission-queued-text mt-1 text-sm text-muted">
{t("toolCall.question.queuedHint")}
</p>
</div>
</div>
</Show>
<Show when={!isQueuedBehindInterruption()}>
<div class="tool-call-permission-body">
<div class="flex flex-col gap-2">
<For each={questions()}>
Expand Down Expand Up @@ -340,11 +368,17 @@ export function QuestionToolBlock(props: QuestionToolBlockProps) {
</div>
</Show>

<Show when={!props.active() && props.request()}>
<p class="tool-call-permission-queued-text px-3 pb-3">{t("toolCall.question.queuedText")}</p>
</Show>
{/*
Task 059: the previous fallback queuedText was rendered alongside
fully-disabled radios. The dedicated `isQueuedBehindInterruption`
branch above now owns the inactive presentation, so the legacy
hint here would be dead code. If the question is in any other
non-active terminal state (already answered) the answered styling
keeps it readable without an extra hint.
*/}
</div>
</div>
</Show>
</div>
</Show>
)
Expand Down
Loading
Loading