Skip to content

Commit da32d09

Browse files
arul28claude
andauthored
Mobile Models Registry (#218)
* ios(work): register Droid model group and tighten question card UX - Add Droid provider tab with Anthropic/OpenAI/Google/Factory sub-providers to the model picker registry. - Drop the standalone reasoning-effort segmented row; gpt-5 mini exposes only medium/high. - Hide redundant single-provider filter rows for Claude/Codex tabs. - Auto-scroll the structured-question card above the keyboard when its freeform field gains focus, swap the page dots for inline indicators, and always suppress question input tool cards. * ios(work): address Greptile P2 nits — tappable reasoning header + family-first routing - Model picker: tapping the header of a reasoning-capable card now commits the model with `effort: nil` (server default) instead of requiring a tier pill. Users who don't care about effort aren't forced to pick one. - Droid provider routing: prefer `family` over ID substring matches so an Anthropic model whose ID happens to contain "codex" still resolves to the anthropic bucket; substring fallbacks only run when family is empty. * fix(chat): clear staged steer chip on dispatch + allow attaching mid-turn - deriveRuntimeState resolves a steer on any non-queued user_message with the same steerId, so the chip clears after Send-Now (inline dispatch) and other delivery paths. cancelSteer now emits the cancelled notice even when the queue is empty server-side, so the delete button clears stale chips after a dispatch race. - AgentChatComposer no longer short-circuits image paste / file attach when turnActive — the steer endpoint already accepts attachments, the guard just blocked the legitimate steer-with-image case. - shipLane skill + playbook: clarify that ScheduleWakeup and run_in_background task notifications do not autonomously wake an agent inside a Claude Agent SDK v2 chat (e.g. ADE Work chats); SDKSession only advances on a fresh session.send(...). Either poll synchronously inside the active turn or stop and ask the user to re-invoke /shipLane. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iter 3 — runtime-environment banner + rebase onto main Adds a "Runtime Environment" block to the chat session system prompt that names the host (ADE Work chat) and the backing tool (Claude Agent SDK v2, Codex CLI, Cursor ACP, Droid ACP, OpenCode), plus the wake-up semantics for that runtime. This fixes the original confusion that produced the chaotic ship-lane chat on April 30: the Claude Agent SDK v2 chat uses the `claude_code` preset verbatim, so the model believed it was inside the Claude Code CLI and called ScheduleWakeup expecting an autonomous re-invocation. Now the append explicitly says "you are NOT in the CLI; ScheduleWakeup is not honored; Bash run_in_background task notifications are queued until the next user message". - systemPrompt.ts: optional runtime parameter on buildCodingAgentSystemPrompt + describeRuntime() helper covering all five ADE chat runtimes. - agentChatService.ts: pass runtime: "codex-cli" through Codex's developer instructions; prepend the SDK-v2 runtime banner to the Claude V2 systemPrompt append array (above ## ADE Workspace). - systemPrompt.test.ts: 6 new tests pinning the banner content per runtime, and asserting the block is omitted when runtime is not passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ship: iter 4 — address CodeRabbit review on iter 3 (4 comments) - systemPrompt.ts (Major): align ScheduleWakeup wording with the shipLane playbook. "not available" → "not honored — the host accepts the call but never re-invokes you". Test updated. - WorkModelCatalog.swift (Minor): map provider == "factory" into the "droid" group key so Factory-provider models (glm-*, kimi-*, minimax-*) land in the Droid group instead of forming a separate one. - WorkModelPickerSheet.swift (Minor): normalize currentReasoningEffort the same way as the incoming effort before diffing — avoids spurious "changed" comparisons from casing/whitespace drift. - WorkNavigationAndTranscriptHelpers.swift (Major): preserve the malformed-question fallback. Tool-call suppression now only fires when the structured-question payload parses cleanly; on toolResult, skip only when no fallback card exists, otherwise let the result update the rendered card. Mirrors the conditional logic already used in WorkErrorAndMessageHelpers.swift. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11dc11b commit da32d09

14 files changed

Lines changed: 427 additions & 150 deletions

.claude/commands/shipLane.md

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ If `TeamCreate` is genuinely not in scope for this session:
7878

7979
## Scheduling wake-ups
8080

81-
This wrapper is Claude Code-specific. Use `ScheduleWakeup` at the end of each iteration (playbook §5.3) with the same command re-invocation as the `prompt`:
81+
The right primitive depends on the harness this command is running under. Pick one and stick with it for the whole run.
82+
83+
### Claude Code CLI (interactive terminal)
84+
85+
`ScheduleWakeup` is a CLI-native primitive — the CLI scheduler re-invokes the command later without the user typing. Use it at the end of each iteration (playbook §5.3):
8286

8387
```
8488
ScheduleWakeup({
@@ -90,16 +94,30 @@ ScheduleWakeup({
9094

9195
Pass `$ARGUMENTS` through so a PR-number argument is preserved across wake-ups.
9296

93-
Waiting must be token-idle. After scheduling a wake-up, stop the active agent turn completely and let the scheduler re-invoke this command later. Do not keep agents alive in polling loops, do not run `--watch` commands, and do not ask sub-agents to sleep while holding context. Poll only once per scheduled invocation, then either fix, exit, or schedule the next wake.
97+
### Claude Agent SDK chat (e.g. ADE Work chats)
98+
99+
When this command runs inside a `claude-agent-sdk` v2 chat session (`unstable_v2_createSession` / `SDKSession.send` / `stream`), the SDK has **no scheduled-wakeup primitive**. `ScheduleWakeup` accepts the call but never re-invokes — the session only advances when the host calls `session.send(...)`, which only happens on a fresh user message. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user message; they do **not** start an autonomous turn either.
100+
101+
So in an SDK-driven chat, do not pretend you can self-resume:
102+
- Do all polling synchronously inside the current turn (`until ! ... ; do sleep N; done` in a foreground bash). One bounded sleep + one bounded poll per turn — no `run_in_background` if you actually need the result before turn end.
103+
- Or stop the turn cleanly and tell the user to re-ping you when they want the next iteration. Write the updated state file with `status: running` so the next `/shipLane $ARGUMENTS` invocation continues from the right phase.
94104

95-
Other agent CLIs have their own sleep/resume mechanisms. If a Claude Code scheduler is not available, follow the playbook's generic guidance instead of copying `ScheduleWakeup` literally. For Codex-style terminal work, the recommended fallback is a shell sleep that does not involve the model, followed by one one-shot status command, for example:
105+
Do not start a `run_in_background` poller and claim it will wake you — it won't. Do not "probe" the wake mechanism by starting a 30s background sleep and waiting silently; nothing will arrive.
106+
107+
### Other agent CLIs
108+
109+
If neither a CLI scheduler nor an SDK wake is available, fall back to a shell sleep that does not involve the model, followed by one one-shot status command, for example:
96110

97111
```bash
98112
sleep 720 && gh pr checks 185 && gh run list --branch ade/cli-prs-fixes-747d7096 --limit 5
99113
```
100114

101115
That shell process can wait without spending model tokens; the agent should only resume reasoning after the command produces output.
102116

117+
### Common rules (all harnesses)
118+
119+
Waiting must be token-idle. After scheduling a wake-up (or running a foreground sleep), do not keep agents alive in polling loops, do not run `--watch` commands, and do not ask sub-agents to sleep while holding context. Poll only once per scheduled invocation, then either fix, exit, or schedule the next wake.
120+
103121
Do NOT schedule a wake if `status` is `done-clean`, `done-max`, or `blocked` — print the summary and stop.
104122

105123
---
@@ -116,6 +134,21 @@ If any rail fails, exit `blocked` with a clear reason in the state file and stop
116134

117135
---
118136

137+
## Worktree path discipline (CRITICAL — every iteration)
138+
139+
ADE invokes `/shipLane` from a worktree like `/Users/<you>/Projects/<repo>/.ade/worktrees/<lane>/`. The project root (`/Users/<you>/Projects/<repo>/`) **also** exists as a separate git checkout, usually on `main`, with the same files at the same relative paths.
140+
141+
Every `Edit`/`Write` you do MUST target the worktree-prefixed absolute path. Editing the project-root copy lands changes on the wrong branch, leaves the worktree clean, and the iteration's commit silently picks up nothing.
142+
143+
How to keep yourself honest:
144+
145+
- Anchor every edit on the env's working directory: if `pwd` shows `.ade/worktrees/<lane>/`, every Edit `file_path` must start with `.../.ade/worktrees/<lane>/`. If a path begins anywhere else under the project root, that's the wrong target.
146+
- `Read` tool result paths are not authoritative. If a Read resolved to the project-root copy (because of an earlier `cd` to project root for a `gh` or `git fetch` call), re-resolve to the worktree before editing.
147+
- After any sequence of edits and before commit, run `git status` from the worktree. If it's empty but you "just edited" several files, you wrote to the wrong tree — recover via `cd <project-root> && git diff > /tmp/x.patch && git checkout -- <files>`, then `git apply /tmp/x.patch` from the worktree.
148+
- Stay in the worktree directory. Use `git -C <project-root> ...` for one-off project-root reads instead of `cd <project-root>`, so subsequent edits don't accidentally use cached project-root paths.
149+
150+
---
151+
119152
## ADE CLI discovery (Claude Code specific)
120153

121154
This wrapper consumes `ade` everywhere the playbook says to use it. If `command -v ade` returns nothing, do NOT immediately fall back to `gh`. Try the local build first:

apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,44 @@ describe("buildCodingAgentSystemPrompt", () => {
7171
expect(result).toContain("Use the available tools deliberately");
7272
});
7373

74+
describe("runtime environment banner", () => {
75+
it("omits the runtime block when runtime is not provided", () => {
76+
const result = buildCodingAgentSystemPrompt({ cwd: "/x" });
77+
expect(result).not.toContain("## Runtime Environment");
78+
});
79+
80+
it("describes the Codex CLI runtime", () => {
81+
const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "codex-cli" });
82+
expect(result).toContain("## Runtime Environment");
83+
expect(result).toContain("Codex CLI");
84+
expect(result).toContain("No autonomous wake from ADE");
85+
});
86+
87+
it("describes the Claude Agent SDK v2 runtime with wake-up caveat", () => {
88+
const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "claude-agent-sdk-v2" });
89+
expect(result).toContain("## Runtime Environment");
90+
expect(result).toContain("Claude Agent SDK v2");
91+
expect(result).toContain("ScheduleWakeup");
92+
expect(result).toContain("not honored");
93+
expect(result).toContain("never re-invokes");
94+
});
95+
96+
it("describes the Cursor ACP runtime", () => {
97+
const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "cursor-acp" });
98+
expect(result).toContain("Cursor agent via ACP");
99+
});
100+
101+
it("describes the Droid ACP runtime", () => {
102+
const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "droid-acp" });
103+
expect(result).toContain("Factory Droid agent via ACP");
104+
});
105+
106+
it("describes the OpenCode runtime", () => {
107+
const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "opencode" });
108+
expect(result).toContain("OpenCode session");
109+
});
110+
});
111+
74112
it("includes interactive question guidance by default", () => {
75113
const result = buildCodingAgentSystemPrompt({ cwd: "/x" });
76114
expect(result).toContain("ask one concise question");

apps/desktop/src/main/services/ai/tools/systemPrompt.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,50 @@ import { ADE_CLI_AGENT_GUIDANCE } from "../../../../shared/adeCliGuidance";
33
type HarnessMode = "chat" | "coding" | "planning";
44
type HarnessPermissionMode = "plan" | "edit" | "full-auto";
55

6+
/**
7+
* Identifier for the runtime that's actually executing the model. Used to tell
8+
* the agent which harness it's in so it doesn't assume CLI-only primitives
9+
* (like ScheduleWakeup) are available, and so it knows whether autonomous
10+
* wake-ups are possible.
11+
*/
12+
export type AdeRuntimeKind =
13+
| "claude-agent-sdk-v2"
14+
| "codex-cli"
15+
| "cursor-acp"
16+
| "droid-acp"
17+
| "opencode";
18+
19+
function describeRuntime(runtime: AdeRuntimeKind): string[] {
20+
switch (runtime) {
21+
case "claude-agent-sdk-v2":
22+
return [
23+
"**Runtime:** ADE Work chat hosted on the Claude Agent SDK v2 (`unstable_v2_createSession` / `SDKSession`).",
24+
"**Wake-up semantics:** The session only advances when the host calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.",
25+
"**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.",
26+
];
27+
case "codex-cli":
28+
return [
29+
"**Runtime:** ADE Work chat wrapping the Codex CLI as a subprocess. Your turns are driven through the Codex agent loop, but the orchestration host is ADE — slash commands, attachments, and lane scoping come from ADE.",
30+
"**Wake-up semantics:** No autonomous wake from ADE. If you need to wait, prefer `sleep ... && <one-shot command>` so the shell holds the wait without burning model tokens, then resume reasoning when the command produces output.",
31+
];
32+
case "cursor-acp":
33+
return [
34+
"**Runtime:** ADE Work chat wrapping the Cursor agent via ACP (Agent Client Protocol).",
35+
"**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.",
36+
];
37+
case "droid-acp":
38+
return [
39+
"**Runtime:** ADE Work chat wrapping the Factory Droid agent via ACP.",
40+
"**Wake-up semantics:** Each turn is a discrete ACP `prompt` request. There is no autonomous wake; if you need to wait, use a shell `sleep` and surface results in the next user turn.",
41+
];
42+
case "opencode":
43+
return [
44+
"**Runtime:** ADE Work chat wrapping an OpenCode session.",
45+
"**Wake-up semantics:** Turns are driven by ADE through the OpenCode HTTP session. There is no autonomous wake; use a shell `sleep` for waits.",
46+
];
47+
}
48+
}
49+
650
function describePermissionMode(mode: HarnessPermissionMode): string {
751
switch (mode) {
852
case "plan":
@@ -31,11 +75,13 @@ export function buildCodingAgentSystemPrompt(args: {
3175
permissionMode?: HarnessPermissionMode;
3276
toolNames?: string[];
3377
interactive?: boolean;
78+
runtime?: AdeRuntimeKind;
3479
}): string {
3580
const mode = args.mode ?? "coding";
3681
const permissionMode = args.permissionMode ?? "edit";
3782
const toolNames = [...new Set((args.toolNames ?? []).filter((entry) => entry.trim().length > 0))];
3883
const interactive = args.interactive !== false;
84+
const runtime = args.runtime;
3985
const hasMemoryTools = toolNames.some((name) =>
4086
name === "memorySearch"
4187
|| name === "memoryAdd"
@@ -71,6 +117,13 @@ export function buildCodingAgentSystemPrompt(args: {
71117
return [
72118
`You are ADE's software engineering agent working in ${args.cwd}.`,
73119
"This session is bound to that worktree. Read, edit, and run commands only inside this path unless ADE explicitly relaunches you in a different lane.",
120+
...(runtime
121+
? [
122+
"",
123+
"## Runtime Environment",
124+
...describeRuntime(runtime),
125+
]
126+
: []),
74127
"",
75128
"## Mission",
76129
describeMode(mode),

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2637,6 +2637,7 @@ function buildCodexDeveloperInstructions(args: {
26372637
mode: promptMode,
26382638
permissionMode: toHarnessPermissionMode(args.session.permissionMode),
26392639
interactive: true,
2640+
runtime: "codex-cli",
26402641
});
26412642
}
26422643

@@ -10539,6 +10540,11 @@ export function createAgentChatService(args: {
1053910540
type: "preset",
1054010541
preset: "claude_code",
1054110542
append: [
10543+
"## Runtime Environment",
10544+
"**Runtime:** ADE Work chat hosted on the Claude Agent SDK v2 (`unstable_v2_createSession` / `SDKSession`). The `claude_code` preset above is the same system prompt the Claude Code CLI uses, so you may think you're in the CLI — you are NOT. You are inside an ADE-hosted SDK session.",
10545+
"**Wake-up semantics:** The session only advances when ADE calls `session.send(...)`, which fires on a fresh user message. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.",
10546+
"**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.",
10547+
"",
1054210548
"## ADE Workspace",
1054310549
`ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`,
1054410550
"Read, edit, and run commands only inside that worktree. Do not switch to project root, another lane, or another repo unless ADE explicitly relaunches you there.",
@@ -14052,9 +14058,12 @@ export function createAgentChatService(args: {
1405214058

1405314059
const queue = runtime.pendingSteers;
1405414060
const idx = queue.findIndex((s) => s.steerId === steerId);
14055-
if (idx === -1) return;
14056-
14057-
queue.splice(idx, 1);
14061+
if (idx !== -1) {
14062+
queue.splice(idx, 1);
14063+
}
14064+
// Always emit the cancelled notice — even when the steer already left the
14065+
// server-side queue (e.g. dispatched inline before this call landed) — so
14066+
// the client display clears the staged chip on the delete-button path.
1405814067
emitChatEvent(managed, {
1405914068
type: "system_notice",
1406014069
noticeKind: "info",

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -795,7 +795,6 @@ export function AgentChatComposer({
795795

796796
const addFileAttachments = async (files: FileList | null | undefined) => {
797797
if (!files?.length) return;
798-
if (turnActive) return;
799798
if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return;
800799
if (fileAddInProgressRef.current) return;
801800
fileAddInProgressRef.current = true;
@@ -847,7 +846,6 @@ export function AgentChatComposer({
847846

848847
const addNativeClipboardImageAttachment = async () => {
849848
if (!canAttach) return;
850-
if (turnActive) return;
851849
if (parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS) return;
852850
if (fileAddInProgressRef.current) return;
853851
fileAddInProgressRef.current = true;

apps/desktop/src/renderer/components/chat/AgentChatPane.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,9 +473,17 @@ export function deriveRuntimeState(events: AgentChatEventEnvelope[]): {
473473
turnActive = event.turnStatus === "started";
474474
} else if (event.type === "done") {
475475
turnActive = false;
476-
} else if (event.type === "user_message" && event.steerId && event.deliveryState === "queued") {
477-
if (!resolvedSteerIds.has(event.steerId)) {
478-
steerMap.set(event.steerId, { steerId: event.steerId, text: event.text });
476+
} else if (event.type === "user_message" && event.steerId) {
477+
if (event.deliveryState === "queued") {
478+
if (!resolvedSteerIds.has(event.steerId)) {
479+
steerMap.set(event.steerId, { steerId: event.steerId, text: event.text });
480+
}
481+
} else {
482+
// "inline" / "delivered" / "failed" — the steer left the queue, so
483+
// clear it from the display. Without this the chip stays staged after
484+
// the user clicks "Send Now" or after a queued steer is delivered.
485+
steerMap.delete(event.steerId);
486+
resolvedSteerIds.add(event.steerId);
479487
}
480488
} else if (event.type === "system_notice" && event.steerId) {
481489
// "cancelled" or "Delivering" notices resolve the steer

apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -676,12 +676,14 @@ struct WorkStructuredQuestionCard: View {
676676
/// forwards this as one `chat.respondToInput` call.
677677
let onSubmitAll: @MainActor ([String: AgentChatInputAnswerValue], String?) async -> Void
678678
let onDecline: @MainActor () async -> Void
679+
var onFreeformFocusChange: ((Bool) -> Void)? = nil
679680

680681
@State private var currentPage: Int = 0
681682
@State private var singleQuestionFreeformText: String = ""
682683
@State private var selections: [String: Set<String>] = [:]
683684
@State private var freeformByQuestion: [String: String] = [:]
684685
@State private var expandedPreviews: Set<String> = []
686+
@FocusState private var freeformFocused: Bool
685687

686688
private var isPaged: Bool { question.questions.count > 1 }
687689
private var activeQuestion: WorkPendingQuestion {
@@ -699,12 +701,11 @@ struct WorkStructuredQuestionCard: View {
699701
ForEach(Array(question.questions.enumerated()), id: \.offset) { index, q in
700702
questionPage(q)
701703
.tag(index)
702-
.padding(.bottom, 24)
704+
.padding(.bottom, 4)
703705
}
704706
}
705-
.tabViewStyle(.page(indexDisplayMode: .always))
706-
.indexViewStyle(.page(backgroundDisplayMode: .always))
707-
.frame(minHeight: 280)
707+
.tabViewStyle(.page(indexDisplayMode: .never))
708+
.frame(minHeight: 240)
708709
} else {
709710
questionPage(activeQuestion)
710711
}
@@ -716,6 +717,9 @@ struct WorkStructuredQuestionCard: View {
716717
footerRow
717718
}
718719
.adeGlassCard(cornerRadius: 18, padding: 14)
720+
.onChange(of: freeformFocused) { _, focused in
721+
onFreeformFocusChange?(focused)
722+
}
719723
}
720724

721725
@ViewBuilder
@@ -797,10 +801,12 @@ struct WorkStructuredQuestionCard: View {
797801
let binding = freeformBinding(for: q)
798802
if q.isSecret {
799803
SecureField(q.options.isEmpty ? "Response" : "Optional response", text: binding)
804+
.focused($freeformFocused)
800805
.adeInsetField(cornerRadius: 14, padding: 12)
801806
.disabled(busy)
802807
} else {
803808
TextField(q.options.isEmpty ? "Response" : "Optional response", text: binding, axis: .vertical)
809+
.focused($freeformFocused)
804810
.lineLimit(1...4)
805811
.autocorrectionDisabled(false)
806812
.textInputAutocapitalization(.sentences)
@@ -812,22 +818,45 @@ struct WorkStructuredQuestionCard: View {
812818
@ViewBuilder
813819
private var footerRow: some View {
814820
HStack(spacing: 10) {
821+
Button("Decline") {
822+
Task { await declineQuestion() }
823+
}
824+
.buttonStyle(.glass)
825+
.tint(ADEColor.danger)
826+
.disabled(busy)
827+
828+
Spacer(minLength: 8)
829+
830+
if isPaged {
831+
pageIndicator
832+
Spacer(minLength: 8)
833+
}
834+
815835
Button(submitLabel) {
816836
Task { await submitAll() }
817837
}
818838
.buttonStyle(.glassProminent)
819839
.tint(ADEColor.accent)
820840
.disabled(busy || !canSubmit)
841+
}
842+
}
821843

822-
Spacer(minLength: 0)
823-
824-
Button("Decline") {
825-
Task { await declineQuestion() }
844+
@ViewBuilder
845+
private var pageIndicator: some View {
846+
HStack(spacing: 6) {
847+
ForEach(0..<question.questions.count, id: \.self) { index in
848+
Button {
849+
withAnimation(.easeInOut(duration: 0.18)) { currentPage = index }
850+
} label: {
851+
Circle()
852+
.fill(index == currentPage ? ADEColor.accent : ADEColor.textMuted.opacity(0.35))
853+
.frame(width: index == currentPage ? 7 : 6, height: index == currentPage ? 7 : 6)
854+
}
855+
.buttonStyle(.plain)
856+
.accessibilityLabel("Question \(index + 1) of \(question.questions.count)")
826857
}
827-
.buttonStyle(.glass)
828-
.tint(ADEColor.danger)
829-
.disabled(busy)
830858
}
859+
.padding(.horizontal, 4)
831860
}
832861

833862
private var submitLabel: String {

0 commit comments

Comments
 (0)