Skip to content

Ask Logue part 6: the island as glass, with starters, a level meter and a Return hint - #75

Open
shanforge wants to merge 13 commits into
mainfrom
shan/issue-62-island-glass
Open

Ask Logue part 6: the island as glass, with starters, a level meter and a Return hint#75
shanforge wants to merge 13 commits into
mainfrom
shan/issue-62-island-glass

Conversation

@shanforge

@shanforge shanforge commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Part of #56. Closes #62 — all eight boxes.

Stacked on #74 — review that first; this branch contains it.

The seam

The island rendered in two materials. The prompt pill was filled with a fixed
near-opaque colour and the transcript directly above it used .ultraThinMaterial, so
whenever both were on screen the join between them was visible: one panel looked like
glass and the other like a rectangle stuck to the screen.

The legibility bug underneath it

Nothing pinned the island's colour scheme. Every foreground in it is white — bubbles,
placeholder, icons, chips — and Material is not. In Light appearance
.ultraThinMaterial resolves to a light frost, so the transcript drew white text on a
white-ish panel. Color.primary.opacity(0.06), used for the assistant bubble and the
header buttons, inverted with it.

The island now commits to dark rather than having half its palette adapt. It is a HUD
over someone else's window, not a document, so it does not follow the system — which
is what makes it legible in both appearances, and what makes every white already in
it correct by construction.

The rule is decided, not picked

The island is the only surface in Logue with no control over what is behind it — a
white document, a photograph, a video at full brightness. IslandSurface is that
decision, free of SwiftUI so the matrix is testable without mounting a panel:

  • Reduce Transparency stops the island being glass at all rather than making it
    slightly less glass, and takes the shadow with it — a soft shadow under an opaque
    panel is the effect that setting exists to remove.
  • Increase Contrast may only ever deepen the veil and harden the edge. Stated as a
    comparison rather than as numbers, so retuning the palette cannot quietly invert it.
  • The scrim can never fall below a floor. Glass with no veil over a white document
    is white text on a white panel — the failure the type exists to prevent.
  • The edge is always drawn. Against a dark desktop an island with no stroke has no
    boundary and reads as a hole cut in the screen.

Neither accessibility setting was honoured by the island before this.

Tests

7 cases in IslandSurfaceTests, none needing a model. Mutation-checked: dropping the
scrim below the floor turns "Every treatment keeps the scrim above the floor" red for
every combination.

Worth pinning because all of it is invisible in the one configuration a developer
actually runs — transparency on, contrast standard, a dark wallpaper. The settings
that break it are ones you have to go and turn on.

Verification

  • xcodebuild build — succeeds
  • ./scripts/test-no-llm.sh1623 tests in 142 suites pass
  • SwiftFormat 0.62.1 --lint — 0/541 files require formatting
  • SwiftLint 0.65.0 --strict — 0 violations in 682 files

Draft until it has been seen

This one is entirely visual and I cannot drive the island — it needs Accessibility
permission, which every rebuild revokes. What to look at, over a bright wallpaper
and then a dark one:

  1. System appearance Light. The island is dark glass and every word in it is
    readable. This is the case that was broken.
  2. System appearance Dark. Unchanged from before, ideally.
  3. Pill and transcript together — one continuous material, no seam.
  4. Accessibility → Display → Reduce transparency on: the island goes solid, the
    desktop no longer moves under it, and the drop shadow is gone.
  5. Accessibility → Display → Increase contrast on: the veil deepens and the edge
    hardens. Nothing gets lighter.

Box 2 — starters, the level meter, and the Return hint

Three things the island never said, all about the moment before a send.

Starters on an empty island

The chips Home already offers, from the same rule (HomeSuggestions) and the same
reading of the workspace — so an empty island suggests summarising the meeting you
have not summarised rather than a hardcoded list that goes stale.

Which chips appear was already a tested rule; how the workspace is read into it was
three private computed properties inside AgentChatView, which is why the island had
no starters. Copying them would have been the wrong version — workspaceIsEmpty alone
spans three stores, and a copy that forgot spaces offers a returning user the first-run
chips. The stores-loaded guard has the same shape of bug behind it, so it moved too:
nothing renders until all three stores report.

A live level meter while the mic is open

The VoiceInputIndicator the two in-app chat panels already mount. The island streams
the partial transcript straight into the field so it can be edited before sending, so
the indicator is handed none — what it adds here is the level, which is the only thing
that says the mic is actually hearing anything, and a stop target bigger than the mic
glyph.

What Return will do, once it will do anything

Shown only when there is something to send. A hint that is always there is chrome, and
the island has one line to spend. Shift-Return is deliberately not advertised — it is
the escape hatch from the hint, not a second thing to learn.

Click-through for box 2

  1. Open the island with an empty workspace — three capability chips. With meetings and
    documents in it — chips that name them. Tapping one fills the field and focuses it.
  2. Hold the mic. The meter moves with your voice, and Stop on the indicator ends it.
  3. Type one character: the appears. Delete it: it goes.

Box 3 — tool cards readable at island width

Two defects in ToolExecutionCard.formatArguments, both invisible in a full window and
both plain on a 700pt card whose row has to hold the summary, the status, and the
Approve and Deny buttons at once.

It was unordered. It mapped over a Dictionary, which has no order, so the same
call rendered query: standup, limit: 5 on one pass and limit: 5, query: standup on
the next. With the line truncated, the argument you can actually read changed as the
view re-rendered
. Keys are sorted now — alphabetical is arbitrary, but it is the same
arbitrary every time, which is the property that matters when you are comparing one
card with the one above it.

It was unbounded. update_document carries the whole new body as an argument, so
the string handed to Text was the length of a document — 20,009 characters in the
test that now guards it. lineLimit(1) hid the tail, but the text was still laid out,
pushing everything after it on the row for room that was never going to be used. Values
are flattened onto one line too, so a multi-paragraph argument stops looking like a
short one with no ellipsis to say otherwise.

The approval buttons no longer compress. How to refuse must never be what gives way,
and the summary beside them is already truncating.

Tests

9 cases in ToolArgumentSummaryTests. Mutation-checked both ways:

Mutation Turns red
drop the clamps "A huge argument cannot make the line huge" (20,009 chars), "Truncation never exceeds the budget it was given"
dict.keys.map instead of .sorted() "Keys are ordered by name", "One long value does not crowd out the others"

Worth saying plainly: "The same arguments always produce the same line" does not on
its own catch the ordering regression — a dictionary's iteration order is seeded per
process, so within one run the old code answered consistently too. keysAreSorted is
the one that catches it. There is a note in the file saying so, because a test that
looks like a guard and is not is worse than no test.

Click-through for box 3

  1. Ask something that calls a tool with a long argument (edit a document). Expand the
    card: the argument line truncates in the middle and the row's controls stay put.
  2. Trigger a destructive tool from the island. Approve and Deny are fully visible and
    unsqueezed however long the tool name is.

Box 4 — approval prompts that say what they will do, and to what

Every destructive tool takes a UUID. The card had five hand-written sentences and a
fallback of "Agent wants to run \(toolName)", and none of them named the target — so
"Agent wants to delete a document" was the whole of what the user was told before
being asked for Touch ID. Which document was not knowable from the card at all.

Now: Delete “Q3 Planning”. Write to “~/notes/todo.md”. Draft an email to “sam@example.com”. And delete_space, which trashes every document and meeting inside
it and its children, says so: Delete, with everything in it, “Work”.

ToolApprovalPrompt holds the wording and which argument carries the target, pure so
both are testable; ToolApprovalTargetResolver is the half that asks the stores.

Reminders and calendar events deliberately resolve to nothing. Both live behind an
EventKit permission, and prompting for access as a side effect of drawing an approval
card is not acceptable in the one card whose job is to be trustworthy. They show the
action alone — honest about not knowing the name — rather than a UUID, which tells the
user nothing and reads as a bug at the moment they are deciding whether to trust the
agent.

The coverage test found two on its first run

It walks the real registry, so a tool that asks for approval without a sentence is a red
build rather than a card reading "Run delete_everything" over a Touch ID button. On its
first run it failed with:

missing → ["fetch_contacts", "get_location"]

Both read private data. Both had been rendering as "Agent wants to run get_location".
Neither was on my list — the test was.

AgentCoordinator gained allKnownTools() so that walk cannot go stale, and the
registry is now built from it rather than from a second list. No behaviour change: web
tools are filtered out unless opted in, exactly as before, and they are not in the
Settings per-tool list so the ordering of the disable filter is unobservable.

The approval sentence also renders as primary text over two lines — a title cut in half
is how you approve the wrong thing. Every other card stays on one line, because it is a
record of something that already happened.

Click-through for box 4

  1. Ask the island to delete a document by name. The card names that document, and
    Reject leaves it alone.
  2. Ask it to delete a space. The card says everything inside goes too.
  3. Ask for something needing contacts or location — the card says which private thing
    is about to be read.

Box 5 — the gap between a send and the first token

The island had nothing there. Between pressing Return and the first token it showed
the user's own bubble and empty space — which is exactly when someone concludes the send
did not land and presses Return again. Once the assistant message existed it showed a
literal "..." pushed through the markdown renderer, in a grey bubble.

It now shows the pulsing row the main window shows, saying what the agent is actually
doing — "Searching the web…", "Reading…" — rather than a static "Thinking…".

The wording already had one definition in UICopy.Status.describe(toolName:). When to
show it did not: it was written out longhand at each place that needed it, and came out
differently at each. AgentThinkingState is that half, and both surfaces read it — the
main window's list indicator included.

The input worth naming

pendingAnswerText is what has arrived of the answer being produced now, not "the
last assistant message". The latter is the previous answer and is non-empty for the
whole of every later gap, so an indicator keyed on it appears once and never again.
There is a case pinning exactly that, because it is the mistake the shape invites.

A tool card also suppresses the row: two things claiming to explain the same pause is
worse than one, and the card is the more specific, since it names the tool.

Click-through for box 5

  1. Send from the island and watch the space between your bubble and the answer — a
    pulsing row, not a gap and not three dots in a box.
  2. Send something that searches the web: the row says so, then gives way to the tool
    card, then to the answer.
  3. Send a second message in the same thread — the row appears again. Keyed on the last
    assistant message it would not.

Box 6 — chips that do not steal the bar's line

The island floated its chips in an .overlay offset 34pt above the pill. Two
consequences:

  • They took no layout space, so with a conversation on screen they drew over the
    bottom of the transcript.
  • Nothing bounded them, so attaching six files ran the row past the island's width
    and off both ends.

The row is now in the VStack, and ComposerChipRow decides what fits.

Modes are never hidden

The ordering rule is the part worth stating. A mode chip says what the send is about to
do — search the web, spend minutes on Deep Research — and a hidden one is a send the
user did not know they were making. So a limit too small to hold the modes is exceeded
rather than met by dropping one, and there is a case pinning that.

Attachments overflow into a +3 more counter instead, which loses nothing: the files
are still attached. The counter also pays for its own slot — four slots and six files
gives three chips and "+3 more", not four chips and "+2 more", which would be five
things in a four-slot row.

The counter carries the names it hides, as its tooltip and as its VoiceOver label. A
chip that hides a file with no way to find out which one is worse than a row that
overflows.

Modes are a value now rather than two booleans added up wherever a count was needed.

Tests

9 cases, including a sweep asserting shown + hidden == total across every combination
of 0–40 attachments and 0–2 modes — nothing is ever silently dropped.

Click-through for box 6

  1. Attach six files. Three chips and "+3 more"; hover it for the rest. Remove one and
    the counter drops.
  2. With a conversation on screen, attach a file — the chips sit between the transcript
    and the pill rather than on top of the transcript.
  3. Turn on both Search and Deep Research and attach four files: both mode chips are
    still there.

Box 7 — VoiceOver labels, and the keyboard path

Almost every control in the island carried only .help(…). On macOS that becomes the
accessibility hint, not the label
, so VoiceOver fell back to the only other thing it
had — the SF Symbol name. The send button announced "arrow up". The close button
announced "xmark". And the two mode toggles announced nothing at all about being on,
which is the single thing a toggle exists to convey.

IslandControlCopy holds the name, the state and the tooltip together so they cannot
drift, and one .islandControl(…) modifier applies all three. Writing them out
separately at each call site is exactly how one gets forgotten — which is how this
happened in the first place.

Send and Stop are named differently rather than being one control in two states. A
button that keeps the name "Send" while it cancels is how someone stops a run they meant
to let finish.

The test

It walks every control in every state and rejects a dotted, lowercase label — the shape
an SF Symbol name has. Mutation-checked: restoring "arrow.up" on the send button turns
it red on both assertions.

Keyboard: Escape already dismisses the island (handled in CommandCenterController),
Return sends, Shift-Return inserts a newline and ⌘Return sends from anywhere in the
field. Those were in place; what was missing was everything above.

Click-through for box 7

  1. Turn on VoiceOver and tab through the island. Every control announces a name, and
    the two toggles announce On or Off.
  2. Start a run: the send button announces "Stop", not "Send".

Box 8 — Reduce Motion

The island is the most animated surface in Logue: it slides in over another app, springs
open on the first message, scales its chips in and out, and runs two forever pulses.
None of it consulted Accessibility → Display → Reduce motion, so someone who asked
the system for less movement got the most of it here.

IslandMotion is the policy, and it turns on decoration versus information.

A spring, a slide and a scale say nothing the layout does not already say, so they go —
replaced by a cross-fade rather than by nothing, because removing the transition outright
turns every panel into a jump cut, which is not what the setting asks for.

A pulsing dot is the harder case: it is the only thing on screen saying the agent is
still working. It stops anyway — but only because every row it appears in also says so
in words. Removing it from somewhere without that text would be removing the signal, not
the decoration.

The level meter is deliberately untouched. Its movement is the reading. A still
meter is not a calmer meter, it is a broken one.

Springs are removed rather than shortened: a spring is the movement, and making it quick
makes it a flinch.

PulsingDot reads the setting itself rather than at each call site, so a dot added
somewhere new cannot reintroduce a forever-repeating animation. That also fixes it in the
main window, which had the same gap.

Click-through for box 8

  1. Accessibility → Display → Reduce motion on. Open the island: it fades rather than
    springs, chips fade rather than scale, and the thinking dot is visible but still.
  2. The mic level meter still moves. It is data, not decoration.

The prompt pill was filled with a fixed near-opaque dark colour and the transcript
above it used .ultraThinMaterial, so one island rendered in two materials with the
seam between them visible whenever both were on screen.

Worse, nothing pinned the island's colour scheme. Every foreground in it is white,
and Material is not: in Light appearance .ultraThinMaterial is a light frost, so the
transcript drew white text on a white-ish panel.

IslandSurface is the rule, free of SwiftUI so the matrix is testable without mounting
a view. Reduce Transparency stops the island being glass at all rather than making it
slightly less glass, and takes the shadow with it; Increase Contrast may only ever
deepen the veil and harden the edge, never lighten either; and the scrim can never
fall below a floor, because glass with no veil over a white document is white text on
a white panel.

Parked on its own branch: this is #62 (part 2), not part 1.

Part of #62.
Both panels now paint through IslandSurface, so the island stops rendering in two
materials — the prompt pill was filled with a fixed near-opaque colour while the
transcript directly above it was frosted, and the seam showed whenever both were up.

The legibility bug underneath it: nothing pinned the island's colour scheme. Every
foreground in it is white and Material is not, so in Light appearance
.ultraThinMaterial resolved to a light frost and the transcript drew white text on a
white-ish panel. The island now commits to dark rather than half its palette adapting
— it is a HUD over someone else's window, not a document — which is what makes it
legible whichever appearance the system is in.

Reduce Transparency and Increase Contrast are honoured for the first time here; the
rule and its cases came with the previous commit.

Part of #62.
Which chips appear was already a tested rule in HomeSuggestions; how the workspace is
read into that rule was three private computed properties inside AgentChatView. That
is why the island has no starters — the rule was reachable and the inputs were not.

Copying them would have been the wrong version: workspaceIsEmpty alone spans three
stores, and a second copy that forgot spaces offers a returning user the first-run
chips. The stores-loaded guard has the same shape of bug behind it.

Part of #62.
Three things the island never said, all of them about the moment before a send.

Starters are the chips Home already offers, from the same rule and the same reading of
the workspace — so an empty island suggests summarising the meeting you have not
summarised rather than a hardcoded list that goes stale. Held back until the stores
report, because offering first-run chips to a returning user is worse than offering
nothing.

The level meter is the indicator the two in-app chat panels already mount. The island
streams the partial transcript straight into the field so it can be edited before
sending, so the indicator is handed none — what it adds is the level, which is the only
thing that says the mic is hearing anything, and a stop target bigger than the mic glyph.

The Return hint appears only once there is something to send. A hint that is always
there is chrome, and the island has one line to spend.

Part of #62.
@shanforge shanforge changed the title Ask Logue part 6: the island is glass, and legible over any wallpaper Ask Logue part 6: the island as glass, with starters, a level meter and a Return hint Aug 21, 2026
Two defects in formatArguments that only became visible at island width, where the
card is 700pt rather than a full window and the row has to hold the summary, the
status and the Approve and Deny buttons at once.

It mapped over a Dictionary, which has no order, so the same call rendered
`query: standup, limit: 5` on one pass and `limit: 5, query: standup` on the next —
and with the line truncated, the argument you could actually read changed as the view
re-rendered. Keys are sorted now: alphabetical is arbitrary, but it is the same
arbitrary every time.

And it was unbounded. update_document carries the whole new body as an argument, so
the string handed to Text was the length of a document — 20,009 characters in the
test that now guards it. lineLimit(1) hid the tail but the text was still laid out,
pushing everything after it on the row for room that was never going to be used. Values
are also flattened onto one line, so a multi-paragraph argument stops looking like a
short one.

The approval buttons no longer compress: how to refuse must never be what gives way,
and the summary beside them is already truncating.

Part of #62.
Every destructive tool takes a UUID. The card had five hand-written sentences and a
fallback of "Agent wants to run <toolName>", and none of them named the target — so
"Agent wants to delete a document" was the whole of what the user was told before being
asked for Touch ID. Which document was not knowable from the card at all.

ToolApprovalPrompt is the wording and which argument holds the target, pure so both are
testable; ToolApprovalTargetResolver is the half that has to ask the stores. Reminders
and calendar events deliberately resolve to nothing: both live behind an EventKit
permission, and prompting for access as a side effect of drawing an approval card is
not acceptable in the one card whose job is to be trustworthy. They show the action
alone, which is honest, rather than a UUID, which is not.

The coverage test walks the real registry, so a destructive tool with no sentence is a
red build. It found two on its first run — fetch_contacts and get_location, both
reading private data, both previously rendering as "Agent wants to run get_location".
AgentCoordinator gained allKnownTools() so that walk cannot go stale; the registry is
built from it rather than from a second list.

The approval sentence also renders as primary text over two lines. A title cut in half
is how you approve the wrong thing; everything else on a card is a record of something
that already happened and stays on one line.

Part of #62.
The island had nothing there. Between pressing Return and the first token it showed the
user's own bubble and empty space — which is exactly when someone concludes the send did
not land and presses Return again. Once the assistant message existed it showed a literal
"..." pushed through the markdown renderer, in a grey bubble.

It now shows the pulsing row the main window shows, saying what the agent is actually
doing rather than a static "Thinking…".

The wording already had one definition in UICopy.Status.describe. When to show it did
not — it was written out longhand at each place that needed it, and came out differently
at each. AgentThinkingState is that half, and both surfaces read it.

The input worth naming is pendingAnswerText: what has arrived of the answer being
produced *now*, not the last assistant message. The latter is the previous answer and is
non-empty for the whole of every later gap, so an indicator keyed on it appears once and
never again. There is a case pinning that.

Part of #62.
The island floated its chips in an overlay offset 34pt above the pill. That meant two
things: they took no layout space, so with a conversation on screen they drew over the
bottom of the transcript; and nothing bounded them, so attaching six files ran the row
past the island's width and off both ends.

The row is now in the VStack, and ComposerChipRow decides what fits. The ordering rule
is the part worth stating: modes are never hidden. A mode chip says what the send is
about to do — search the web, spend minutes on Deep Research — and a hidden one is a
send the user did not know they were making. Attachments overflow into a counter
instead, which loses nothing, because the files are still attached.

The counter carries the names it hides, as a tooltip and as its VoiceOver label. A chip
that hides a file with no way to find out which is worse than a row that overflows.

Modes are a value now rather than two booleans added up wherever a count was needed.

Part of #62.
Almost every control in the island carried only .help(…). On macOS that becomes the
accessibility hint, not the label — so VoiceOver fell back to the only other thing it
had, the SF Symbol name. The send button announced "arrow up"; the close button
announced "xmark"; and the two mode toggles announced nothing about being on, which is
the single thing a toggle exists to convey.

IslandControlCopy holds the name, the state and the tooltip together so they cannot
drift, and .islandControl applies all three at once — writing them out separately at
each call site is exactly how one gets forgotten, which is how this happened.

Send and Stop are named differently rather than being one control in two states. A
button that keeps the name "Send" while it cancels is how someone stops a run they meant
to let finish.

The test walks every control in every state and rejects a dotted, lowercase label.
Restoring "arrow.up" on the send button turns it red.

Part of #62.
The island is the most animated surface in Logue — it slides in over another app,
springs open on the first message, scales its chips in and out, and runs two forever
pulses. None of it consulted Accessibility → Display → Reduce motion, so someone who
asked the system for less movement got the most of it here.

IslandMotion is the policy, and it turns on decoration versus information. A spring, a
slide and a scale say nothing the layout does not already say, so they go — replaced by
a cross-fade rather than by nothing, since removing the transition outright turns every
panel into a jump cut.

A pulsing dot is the harder case: it is the only thing on screen saying the agent is
still working. It stops anyway, but only because every row it appears in also says so in
words. The level meter is deliberately untouched — its movement is the reading, and a
still meter is not a calmer meter, it is a broken one.

Springs are removed rather than shortened. A spring is the movement; making it quick
makes it a flinch.

PulsingDot reads the setting itself rather than at each call site, so a dot added
somewhere new cannot reintroduce a forever-repeating animation.

Part of #62.
#74 took `shan/issue-61-island-tools-and-research` to `main` carrying review-round
fixes this branch was cut before, so the two had diverged in three files.

Three resolutions worth stating, because in each case one side is not simply newer:

- **The three composer glyphs are gone, not relabelled.** This branch put VoiceOver
  labels on the island's separate attach / web-search / Deep Research buttons; `main`
  had since replaced all three with the shared `ComposerPlusMenu`, which is the thing
  #61 asked for and already names itself for VoiceOver. So `IslandControlCopy.attach`,
  `.webSearch` and `.deepResearch` are deleted rather than kept — copy naming controls
  that no longer exist is copy no test can hold to account, and `IslandControlCopyTests`
  walked exactly those. A note in the enum says where they went.

- **The one-shot flags keep `main`'s island-specific keys.** This branch read
  `oneShotWebSearch`; `main` had moved the island onto `islandOneShot*` so a send here
  cannot disarm a chip armed in the main window. The chip row keeps its
  `ComposerChipRow` bounding and reads the corrected keys.

- **The layout animation keeps `main`'s `hasThread`, not this branch's `hasContent`.**
  `hasThread` is `hasContent || conversationID != nil`, from the fix that stopped an
  island holding a conversation closing itself. Keying the animation on `hasContent`
  would have quietly reverted the question that fix exists to ask.

`stagedChips` also absorbs two things `main` fixed in the copy it replaces: the
attachment's own `iconName` rather than a hardcoded "doc", and `UICopy.Input` titles.

Verified on the merge result: build succeeds, 1656 tests in 145 suites pass,
SwiftFormat 0.62.1 --lint clean over 543 files, SwiftLint 0.65.0 --strict 0 violations
in 687 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3Wpnj9ZmWPKYdFPB1AVY3
@shanforge
shanforge changed the base branch from shan/issue-61-island-tools-and-research to main September 4, 2026 05:22
shanforge and others added 2 commits September 4, 2026 11:34
Four findings, most severe first.

**A refused send showed nothing (blocking).** `localError` was set on both refusal
paths and rendered by none: the banner read `currentError`, which is
`coordinator.lastError(in: conversationID)`. The globally-busy guard in `sendMessage`
returns *before* `ensureConversation()`, so on that path `conversationID` is still nil
and `currentError` can only ever answer nil. Start a run in the main window, open the
island, type, press Return: the guard fires, sets the message, and nothing appears —
which is verbatim the failure its own comment says it exists to prevent. The banner now
reads `displayedError`, local first, and a send that goes ahead clears the last refusal
so a stale "busy elsewhere" cannot sit over a send that worked.

**A document title could reverse the sentence naming it (major).** `flatten` collapsed
whitespace but let control and format characters through. A title carrying U+202E —
which can arrive in a `.md` file dropped into the markdown folder, or from a
`create_document` call a prompt-injected model made — reverses the display of everything
after it, so `Delete “report.txt”` can be made to name a different file on the one card
whose whole job is to be true, directly above a Touch ID prompt. `CLAUDE.md` already
requires stripping control characters from any user string reaching a prompt or label,
and `AgentArgs.sanitize` does it in eight other places.

`ToolApprovalPrompt` and `ToolArgumentSummary` had a copy each of `flatten` and
`clamp`, so the fix is a shared `DisplayText` rather than a third copy — a control that
exists twice is a control that gets fixed once.

The order inside it is the part worth stating: **a newline is itself a control
character** (U+000A is Cc), so stripping controls before splitting on whitespace turned
`first\nsecond` into `firstsecond` — a different value, silently. The existing suite
caught exactly that. Whitespace is exempted from the strip and the split handles it.

Mutation-checked: removing the strip turns six cases red across both files.

**Two definitions of "the workspace is empty" (minor).** The branch extracted
`storesAreLoaded` and the emptiness rule into `HomeSuggestions` so the island could
share them, and left `AgentChatView`'s copies in use — each commented as being the one
definition. Change the rule in one and `HomeLandingView` says empty while the chips say
otherwise: first-run landing above returning-user chips, the drift both comments warn
of. The view now reads the extracted rule. No runtime change — `LogueApp` injects the
same singletons the extraction reads.

**Reduce Motion was read once (minor).** `PulsingDot` consulted it in `onAppear` only,
so turning it on while the island was streaming left the forever-repeating animation
running until the view was rebuilt — the moment the setting is most likely to be
reached for. Now also on change, and the scale resets rather than freezing mid-pulse.

Verified: build succeeds, 1670 tests in 146 suites pass, SwiftFormat --lint clean over
544 files, SwiftLint --strict 0 violations in 689 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3Wpnj9ZmWPKYdFPB1AVY3
…'s fixes

The second round reviewed the first round's changes, which is where the new defects
were.

**`workspaceIsEmpty` asked for work it then threw away.** The fix routed it through
`HomeSuggestions.currentInputs(overdueCount: 0)` — a dummy argument, passed to reach a
function that also finds the most recent unsummarized meeting and counts today's, none
of which a boolean needs. `AgentChatView` then called `currentInputs` twice per render,
once here and once for the chips. It now reads it once as `suggestionInputs` and derives
both from it, which is also what makes the empty state and the chips provably agree
rather than merely currently agree.

**Three `@Environment` stores were left dead by that fix.** `meetingStore`,
`documentStore` and `spaceStore` had no remaining reader in `AgentChatView` or its
extensions. Removed — and the comment above them, which said the view read from the
environment "rather than reaching for the shared singletons", had become the opposite of
what the code does. It now says which two are still injected and why, and where the
other three went.

**`localError` was marked extension-visible and is not.** No file but
`CommandCenterChatView` mentions it, so the `// Extension-visible: +Composer` marker
named an extension that does not use it — the marker is how a reader knows the wider
access is deliberate, so a wrong one is worse than none. Now `private`.

Refuted this round, recorded so they are not re-examined from scratch: the
`minimumScrimOpacity` floor is unenforced in `IslandSurface` but *is* swept by
`IslandSurfaceTests` across the whole matrix, which is this codebase's stated way of
holding a pure rule to account; and `IslandMotion.allowsPulse` stopping every pulse
looked like it could remove the signal rather than the decoration, but all five
`PulsingDot` call sites pair it with words, so the claim its doc makes holds today.

Verified: build succeeds, 1670 tests in 146 suites pass, SwiftFormat --lint clean over
544 files, SwiftLint --strict 0 violations in 689 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3Wpnj9ZmWPKYdFPB1AVY3
@shanforge

Copy link
Copy Markdown
Collaborator Author

Rebased onto main, and three review rounds

The base was stale. This PR targeted shan/issue-61-island-tools-and-research, which
#74 had already merged into main — so it read as CONFLICTING against a branch that no
longer existed on its own. Retargeted to main and merged main down, which brought in the
review-round fixes this branch was cut before.

Three of those conflicts were real, not just the generated project file:

  • The three composer glyphs are gone, not relabelled. This branch put VoiceOver labels on
    the island's separate attach / web-search / Deep Research buttons; main had since replaced
    all three with the shared ComposerPlusMenu — which is what Ask Logue part 1: one assistant on both surfaces #61 asked for, and which already
    names itself for VoiceOver. So IslandControlCopy.attach, .webSearch and .deepResearch
    are deleted rather than kept, along with the IslandControlCopyTests cases that walked them.
    Copy naming controls that no longer exist is copy no test can hold to account.
  • The one-shot flags keep main's island-specific keys. This branch read
    oneShotWebSearch; main had moved the island onto islandOneShot* so a send here cannot
    disarm a chip armed in the main window. The chip row keeps its ComposerChipRow bounding
    and reads the corrected keys.
  • The layout animation keeps main's hasThread, not this branch's hasContent. Keying
    it on hasContent would have quietly reverted the fix that stopped an island holding a
    conversation from closing itself.

What the review rounds found

Round 1 — 4 findings

Blocking — a refused send showed nothing. localError was set on both refusal paths and
rendered by neither: the banner read currentError, which is
coordinator.lastError(in: conversationID). The globally-busy guard in sendMessage returns
before ensureConversation(), so on that path conversationID is still nil and
currentError can only ever answer nil.

Start a run in the main window, open the island, type, press Return — the guard fires, sets
the message, and nothing appears. That is verbatim the failure its own comment says it exists
to prevent. The banner now reads displayedError, local first; a send that goes ahead clears
the last refusal so a stale "busy elsewhere" cannot sit over a send that worked.

Major — a document title could reverse the sentence naming it. flatten collapsed
whitespace but let control and format characters through. A title carrying U+202E — which can
arrive in a .md file dropped into the markdown folder, or from a create_document call a
prompt-injected model made — reverses the display of everything after it, so
Delete “report.txt” can be made to name a different file. On the one card whose whole job is
to be true, directly above a Touch ID prompt.

ToolApprovalPrompt and ToolArgumentSummary had a copy each of flatten and clamp, so
the fix is a shared DisplayText rather than a third copy — a control that exists twice is a
control that gets fixed once.

The order inside it is worth stating: a newline is itself a control character (U+000A is
Cc), so stripping controls before splitting on whitespace turned first\nsecond into
firstsecond — a different value, silently. The existing suite caught exactly that.

Minor — two definitions of "the workspace is empty" and of storesAreLoaded, each
commented as being the single one; and PulsingDot read Reduce Motion only in onAppear, so
turning it on mid-stream left the forever animation running.

Round 2 — 4 findings, every one in round 1's own fixes

workspaceIsEmpty passed a dummy overdueCount: 0 into a function that also scans for the
most recent unsummarized meeting, and currentInputs ended up called twice per render. Three
@Environment stores were left with no reader, and the comment above them had become the
opposite of what the code did. localError was marked extension-visible and no extension
uses it.

Round 3 — clean

No conflict markers, TODOs or stray print(. Every IslandControlCopy member has a call
site. The one .help-only button in the island carries a Text label, so VoiceOver reads
the name rather than the symbol.

Refuted, recorded so they are not re-examined from scratch

  • The registry reorder is not a behaviour change. buildToolRegistry moved web tools
    before the per-tool disable filter. The Settings UI is the only writer of
    agent.disabledTools and has no web-tool row, so the set can never contain
    web_search/fetch_web_page and the ordering is unobservable.
  • !$0.isArchived in currentInputs is not redundant — activeMeetings filters trashed only.
  • IslandSurface.minimumScrimOpacity is unenforced in the type but swept across the whole
    matrix by IslandSurfaceTests.
  • IslandMotion.allowsPulse stopping every pulse holds today: all five PulsingDot call
    sites pair it with words.

Verification

  • xcodebuild build — succeeds
  • ./scripts/test-no-llm.sh1670 tests in 146 suites pass
  • SwiftFormat 0.62.1 --lint — 0/544 files require formatting
  • SwiftLint 0.65.0 --strict — 0 violations in 689 files

Mutation-checked: removing the control-character strip turns six cases red across
DisplayTextTests and ToolApprovalPromptTests.


What I cannot settle, and what would settle it

Static review says this is defensible. It cannot say the island looks right, and that is
most of what this PR is. Not approving on that basis — the checks below are what is missing:

  1. Light appearance, bright wallpaper. The island is dark glass and every word in it is
    readable. This is the case that was broken.
  2. Pill and transcript together — one continuous material, no seam at the join.
  3. Reduce transparency on — the island goes solid, the desktop stops moving under it, and
    the drop shadow is gone.
  4. Increase contrast on — the veil deepens and the edge hardens. Nothing gets lighter.
  5. The refusal banner, which round 1 fixed. Start something long in the main window, then
    open the island and send: you should now get "Logue is busy with another conversation",
    where before you got nothing. Then send again once the first finishes — the banner clears
    rather than sitting over a send that worked.
  6. Reduce motion on — the panel fades rather than springs, chips fade rather than scale,
    the thinking dot is visible but still, and the mic level meter still moves. Toggle it
    while a reply is streaming: the dot should stop then, not on the next rebuild.
  7. VoiceOver — tab the island: send announces "Send" and "Stop", never "arrow up".

🤖 Generated with Claude Code

https://claude.ai/code/session_01M3Wpnj9ZmWPKYdFPB1AVY3

shanforge added a commit that referenced this pull request Sep 4, 2026
Carries the two review rounds on #75 — the island's error banner fix, the shared
`DisplayText` control-character strip, and the duplicate-definition cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3Wpnj9ZmWPKYdFPB1AVY3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ask Logue part 2: agent UI/UX refinement and the prompt bar

1 participant