Skip to content

Ask Logue part 5: tool history and Deep Research on the island - #74

Merged
shanforge merged 14 commits into
mainfrom
shan/issue-61-island-tools-and-research
Aug 22, 2026
Merged

Ask Logue part 5: tool history and Deep Research on the island#74
shanforge merged 14 commits into
mainfrom
shan/issue-61-island-tools-and-research

Conversation

@shanforge

@shanforge shanforge commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Part of #56. Closes #61.

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

The two things #61 left deliberately partial, which were the last places a question's
answer still depended on which window you asked it from.

The island shows what the agent did

It dropped every tool turn, so a question that made the agent search the web or open a
document showed the answer and no sign of how it was reached. Defensible while the
island was a bare completion call with no tools; once #69 put it on the same agent
loop it became the island claiming credit for work it would not show — on the surface
where trusting the answer matters most, because it floats over someone else's window
with no way to check.

IslandThread turns stored messages into rows, so the ordering is the testable part: a
card has to sit between the question that caused it and the answer that used it. The
card is the main window's ToolExecutionCard, mounted rather than redrawn, with no
conversationID — that is what keeps it read-only, because approval belongs to the
strip pinned above the pill where an answer cannot scroll out of reach.

Two defects fell out of reading rows instead of filtering messages:

  • An assistant turn that only asked for tools carries no prose, and drew an empty grey
    bubble above the card that explained it. Dropped — unless it is the one being
    written, which starts empty and fills in.
  • A trailing tool call marked the previous answer as still streaming.

Deep Research from the island

AskRouter has always taken a deepResearchRequested input and the island always
passed false, so the branch existed and was unreachable. The toggle binds the same
one-shot key as the main window's and is cleared on send for the same reason, the chip
is the same ModeChip, and the seven-step progress strip is mounted, not redrawn.

Two bugs found on the way

  • A Deep Research run belonged to the app, not to a conversation. One run at a
    time app-wide is exactly what makes a global isRunning look sufficient — right up
    until a second surface can start one. Left as it was, a run started on the island
    would have put the main window's input bar into its busy state and painted its
    progress strip, then its clarifying questions, then its failure onto whatever thread
    that window happened to be showing. Now scoped, like AgentRunState, with the owner
    outliving the run because lastError does.
  • Stop only cancelled the agent loop. With Deep Research answering, the button
    reported success, the run carried on, and the report arrived minutes later with
    nothing on screen able to stop it.

Shared rather than copied

  • AgentToolTimeline — pairing a call with its result, and the rule for what to show
    when the stored status and the result disagree, were a private func inside the main
    window's message list. That privacy is the whole reason the island could not show
    tool history: it stores the same messages and only lacked a way to read them back.
    It also settles one disagreement in one place — the island's approval strip filtered
    on the stored status alone, so a call answered in the main window kept its Approve
    and Deny buttons on the island, offering a decision about something that had already
    happened.
  • DeepResearchCoordinator.start(prompt:in:)run expected the user message to be
    in the conversation already, so each surface appended it itself.

Tests

26 new cases across three suites, none needing a model.

Each rule was mutation-checked — the fix reverted, the suite confirmed red, the fix
restored:

Mutation Turns red
awaitingApproval filters on the stored status "A call that has already run is not awaiting approval"
isRunning(in:) ignores its argument "A run is reported only to the conversation that asked for it", "A second run cannot start while one is in flight"

DeepResearchOwnershipTests starts real runs. Nothing in it is async and every body
is synchronous main-actor code, so the spawned task cannot be scheduled before the
defer cancels it — the ownership state is set synchronously by run itself. Making
any case async would hand the pipeline a turn to start on; there is a note in the
file saying so.

Verification

  • xcodebuild build — succeeds
  • ./scripts/test-no-llm.sh1567 tests in 135 suites pass
  • SwiftFormat 0.62.1 --lint — 0/531 files require formatting
  • SwiftLint 0.65.0 --strict — 0 violations in 665 files

Draft until it has been clicked through

Static review cannot tell whether a floating pill renders a tool card at 740pt without
pushing the answer off screen. Held as a draft until these are done, on a build with
Accessibility permission granted and a model loaded:

  1. Ask the island something that uses a tool ("what did I write about X last week").
    The card appears between the question and the answer, collapsed to one line, and
    expands on click.
  2. Ask something destructive. The approval strip is above the pill and answerable;
    answer it in the main window instead and the island's card stops offering the
    buttons.
  3. Turn Deep Research on in the island, send, and watch the strip. Press Stop mid-run
    — it actually stops.
  4. Start a run in the main window and open the island: the island stays idle, with no
    strip and no spinner. Then the reverse.
  5. Open in Logue on a thread with tool cards carries them across.

Reviewed, and what the review changed

Three independent agents reviewed this — correctness/compatibility, craft, and
tests/closing-claim. Every finding was verified against the files before being acted on;
anything I could not personally walk was dropped rather than reported. Two agents found
the blocking bug separately
, which is the strongest signal in the pass.

Blocking — fixed

A Deep Research send was silently swallowed. run refuses a second run with a bare
guard !isRunning, but start appended the user's question before calling it — and
neither surface had a global busy check for Deep Research, because DeepResearchCoordinator
never touches AgentCoordinator's run state. So with a run in flight on the other surface
the question landed in the thread with nothing that would ever answer it: no spinner
(run state is per conversation), no progress strip (it belongs to the other
conversation), no error banner, and the composer already cleared.

It was a regression against main in the other direction too. The main window's input bar
used to read deepResearchCoordinator.isRunning globally, so any run disabled Send.
Scoping that — correct in itself, and the point of this PR — opened the same hole there.

start now checks before it appends and returns nil; both surfaces put the question
back and say why.

Major — fixed

  • Stop reached into the other surface. cancelWhicheverIsRunning's else branch
    called AgentCoordinator.cancel() unconditionally, and that is unscoped: it kills the
    one global task and rejects every pending approval. Pressing New on an idle island
    stopped an answer streaming in the main window. The decision is now AskStopTarget
    pure, tested, with a nothing case — and both surfaces use it.
  • The progress strip and its tests asked different questions. hasActivity(in:) had
    no production caller, and its doc claimed the strip mounted on it while the view rebuilt
    the rule from runningConversationID and isRunning. Five assertions pinned a predicate
    that shipped nothing. The view asks hasActivity now, which absorbed the .failed case.
  • Deep Research ran on an empty prompt. The chip alone routed to research, and a send
    carrying only attachments passes the empty check — so a bare file drop appended an empty
    bubble and ran the seven-step pipeline on "". The router requires text.
  • A call awaiting approval drew two cards — once as a row, once in the pinned strip —
    inside a 420pt panel, only one of them answerable. IslandThread leaves those to the
    strip.
  • Read aloud was two implementations, and they did not sound the same. The shared
    service strips Markdown before speaking; the island's private AVSpeechSynthesizer did
    not, so the main window said "hello" where the island said "asterisk asterisk hello
    asterisk asterisk". Mounting the service also removed a duplicate speakingMessageID, a
    60-second polling loop, and an AVFoundation import.

Tests

Both new rules mutation-checked — each reproduces the exact reported bug:

Mutation Turns red
append before the refusal check "A refused start appends nothing"
the unconditional cancel fallback "With nothing of ours running, Stop stops nothing"

One existing case was strengthened: resultIsNotARow asserted only rows.count == 1, so
emitting the tool result as an assistant message would have kept it green while raw tool
output was shown to the user as if the assistant had said it. It asserts the kind now.

1588 tests in 137 suites, SwiftFormat 0.62.1 and SwiftLint 0.65.0 --strict clean.

On Closes #61

The tests reviewer walked all eight boxes and found them delivered. Two deviations from
the letter of the scope, both deliberate, neither leaving functionality missing:

  • Box 4 — "+ menu shared". The main window has a popover (including a
    Tool settings… entry); the island has three discrete buttons and no Tool-settings
    entry. That is a judgement about a 740pt pill rather than an oversight, but it is not
    literally the same menu. Flagging it rather than quietly counting the box.
  • Box 3. The island still draws its own bubbles rather than mounting the main window's
    message list; only the tool card is genuinely mounted. Documented in the code.

Also worth knowing before merge: Closes #61 is inert while this PR's base is
shan/issue-61-land-stranded-stack.
GitHub only acts on a closing keyword when the PR
merges into the default branch, so #73 must merge first and let this one auto-retarget to
main.

Still owed

Click-through, on a build with Accessibility granted and a model loaded. The fixes above
change send and cancel behaviour, so these are what to hit first:

  1. Start a Deep Research run in one window, then try to send one from the other — you are
    told it is busy, and your question and chip are still there.
  2. With a run going in the main window, press New on the island — the main window's
    answer keeps streaming.
  3. Light the Deep Research chip, attach a file, type nothing, press Return — a normal
    agent turn, not an empty research run.
  4. Read a Markdown-heavy answer aloud from the island — no asterisks spoken.

Found by click-testing, fixed here

The ad-hoc build went to the user, who reported the island closing on clicks it should
survive — including clicking its own Send button — and asked for the prompt bar to use
a single + menu.

The island stopped closing itself

Three separate defects, which is why the symptom looked arbitrary. All three had to go.

Sending emptied the island for a frame or two. AgentCoordinator.send appends the user
message inside a Task while the composer clears inputText/attachments synchronously.
So immediately after Send there were no rows, no draft and nothing staged — a completely
empty island by every rule, and an empty island is one they are all allowed to throw away.
The island owns a thread the moment ensureConversation() runs, which is synchronous and
happens first, so that is what counts now (hasMessageshasConversation).

A click on the island could read as a click somewhere else.
dismissIfClickMissedPanel converted the event to a screen point and tested
panel.frame.contains; its fallback for a nil event window returned window-local
coordinates treated as screen coordinates. It also counted clicks inside the attach
picker as clicks elsewhere — NSOpenPanel.begin is non-modal and runs in-process because
Logue is unsandboxed, so choosing a file tore down the island it was for. Decided by
window identity now, with panels excluded wholesale.

Losing focus buried the island rather than keeping it. focusLoss returned
.sendBehindOtherApps, which dropped the panel to .normal. The panel is
.nonactivatingPanel, so clicking it never re-activates Logue and never restored the
level — the island sat under whatever had just been clicked, with no way back but the
shortcut. That is the "it closed" report. The case is .keep now, which also retires
Trigger.raise and raiseChatPanel().

Two more fell out. Esc pressed in another app used to close a full conversation; it is
now held to the same bar as a stray click, while Esc in the island always works. And the
transparent-area click consulted only the conversation while the click monitor also checked
attachments, so an island holding a staged PDF was torn down beside the pill but survived
elsewhere — both ask CommandCenterChatRule.clickOff.

Agreed behaviour, confirmed with the user: an island holding a conversation stays
floating on top
; only the X, Esc in the island, or the hotkey close it. An empty island
is unchanged.

Every dismissal now logs which of the nine paths fired — an island that vanished for the
wrong reason looks identical to one that vanished for the right one:

log stream --predicate 'subsystem == "com.bitwize.logue" AND category == "CommandCenter"' --style compact

The one that was actually causing it

The three fixes above are all real, and none of them was the reported symptom. It kept
happening, so I went back with logging.

TransparentContainerView decided "empty area, put the island away" whenever no
SwiftUI subview claimed the point
— which is not the same question as "did the click
land outside the island"
. The pill's background, its padding, and the gap between the
transcript and the prompt bar are all inside the island and claimed by no control, so
clicking any of them dismissed it. Clicking the island closed the island, with or without
a conversation in it.

It now asks whether the point falls inside the island's drawn bounds. The hosting view is
pinned to the bottom of a much taller panel and is only as tall as its SwiftUI content, so
its frame is exactly those bounds. Inside: take the click and do nothing with it, rather
than let it fall through to the app behind and bury us. Outside: unchanged.

Worth recording, because it cost a round trip — the dismissal logging added with the first
fix was at .info, which macOS does not persist to the log store. Reproducing the bug
produced an empty log and told me nothing. It is .notice now.

One + menu, mounted by both surfaces

The island spelled attach, web search and Deep Research out as three glyphs while the main
window collapsed them into a Menu — the last item on #61 box 4 still redrawn per
surface
, and it left the island with no way to reach tool settings at all. The pill is now
logo · + · field · mic · send, matching the main window; the mic stays outside the menu on
both.

The awkward part is storage. On macOS a SwiftUI Menu drops Button.action closures and
swallows .toggle() against an @Binding in its deferred-close pipeline — the main
window's toggles only ever worked because they bind @AppStorage, and two comments in the
input bar say so. But the island's flags were deliberately @State, because sharing the
main window's keys let an island send disarm a chip armed over there. Both constraints hold
if the menu takes key names and declares its own storage, so the island got its own
pair; .onAppear clears them, keeping the old dies-with-the-island behaviour.

"Tool settings…" goes through a new AppDelegate.openToolSettings() that activates Logue
first — posting the notification alone opened Settings behind the frontmost app, which
from the island reads as the item doing nothing.

Tests

1599 tests in 138 suites, SwiftFormat 0.62.1 and SwiftLint 0.65.0 --strict clean.
New: the clickOff/escape matrix, a case pinning that a thread with no rows yet is still
content, and the + menu's key isolation (all four one-shot keys distinct).

Mutation-checked: making escape() unconditional turns three cases red. Stated plainly —
the hasConversation wiring lives in the SwiftUI view and has no unit coverage; it is
check 1 below.

Click-through for these two

  1. The reported case: fresh island, type, click Send — the island stays and the
    answer arrives.
  2. Island with a conversation: click Safari → stays floating on top and usable; click the
    main window → stays; Esc in Safari → stays; click in the island then Esc → closes; X →
    closes; hotkey → closes.
  3. Empty island: click Safari → closes; Esc from Safari → closes.
  4. + → "Add photos & files" on an empty island, click inside the open panel → the island
    survives (it used to die mid-pick).
  5. + menu opens with Logue active and with Safari frontmost; toggles check and the
    chips follow; send clears both; close/reopen → both off; the main window's armed chip is
    untouched by an island send; "Tool settings…" brings Settings to the front on the AI tab.

…age list

A tool call and its result are two messages linked by an id, so rendering a card
means walking the conversation to find the message that answers the call. That walk
was a private func inside AgentChatView+Messages, together with the rule for which
status to show when the stored one and the result disagree.

Private to a view is why the island shows no tool history: it runs the same loop and
stores the same messages, and the only thing it lacked was a way to read them back.

Also settles one disagreement in a single place. The island's approval strip filtered
on the stored status alone, so a call the user answered in the main window kept its
Approve and Deny buttons on the island — offering a decision about something that had
already happened. awaitingApproval consults the result, and the mutation that restores
the old filter turns that case red.

Part of #61.
…d timeline

Same rendering, one fewer copy of the pairing walk and the status rule. Proving the
shared type on the surface that already worked, before the island mounts it.

Also corrects a comment that named findResult, which no longer exists.

Part of #61.
The island dropped every tool turn, so a question that made the agent search the web
or open a document showed the answer with no sign of how it was reached. That was
defensible while the island was a bare completion call with no tools; once it moved
onto the same agent loop it became the island claiming credit for work it would not
show — on the surface where trusting the answer matters most, because it floats over
someone else's window with no way to check.

IslandThread turns stored messages into rows, so ordering is the testable part: a
card has to sit between the question that caused it and the answer that used it. The
card is the main window's ToolExecutionCard, mounted rather than redrawn, with no
conversationID — that is what keeps it read-only, because approval belongs to the
strip pinned above the pill where an answer cannot scroll out of reach.

Two things fall out of reading rows rather than filtering messages:

- An assistant turn that only asked for tools carries no prose, and drew an empty
  grey bubble above the card explaining it. Dropped — unless it is the one being
  written, which starts empty and fills in.
- A trailing tool call no longer marks the previous answer as streaming.

EphemeralChatMessage moved next to the row type that carries it. The name stayed:
it is wrong, and renaming it touches every bubble for nothing.

Part of #61.
…ked for it

Deep Research is one-at-a-time app-wide, so a single global isRunning was enough
while the main window was the only place a run could start. It is about to stop being
that, and a global flag is how a run started on the island paints its progress strip,
then its clarifying questions, then its failure onto the main window's thread.

Same rule #61 wrote down for AgentRunState: live run state belongs to a conversation,
not to the app, and the owner outlives the run because lastError and
clarifyingQuestions do.

Also collapses the launch. run() expected the user message to be in the conversation
already, so each surface appended it themselves — a contract the island would have had
to rediscover by finding a run whose question was missing from the thread. start()
does both and returns the question's id for callers that scroll.

Part of #61.
The last route the island could not take. AskRouter has always had a
deepResearchRequested input and the island always passed false, so the branch existed
and was unreachable — the one place a question's answer still depended on which window
you asked it from.

The toggle binds the same one-shot key as the main window's and is cleared on send for
the same reason, the chip is the same ModeChip, and the seven-step progress strip is
mounted rather than redrawn.

Two things this turned up:

- Stop only cancelled the agent loop. With Deep Research answering, the button
  reported success, the run carried on, and the report arrived minutes later with
  nothing on screen able to stop it. It now cancels whichever pipeline is running.
- Staged files are handed back to the pill rather than sent. Deep Research takes no
  attachments on either surface, and silently dropping them is worse than not
  starting.

Part of #61.
…history

Two rules learned landing the rest of part 1. Run-state scoping was written down for
the agent loop, and Deep Research quietly did not follow it — one-at-a-time app-wide
is what makes a global flag look sufficient right up until a second surface can start
a run. And the island's tool history was a filter nobody had revisited since the
island had no tools to show.

Part of #61.
…arch

Two conflicts, both where #73's review fixes met this branch's work.

AgentChatView+Messages: both sides deleted something, and both deletions were right —
this branch removed findResult (it lives in AgentToolTimeline now) and #73 removed the
empty MARK that used to head the section. Kept neither.

CommandCenterChatView: #73 made isWebSearchOnce @State because binding the main window's
UserDefaults key meant the island cleared a chip the user had armed over there. This
branch added isDeepResearchOnce with exactly the same shape, so it had the same bug —
and a worse version of it, because the run it silently disarms is the expensive one the
user deliberately chose. Both are @State now.

The content struct keeps this branch's `hasContent` and #73's `hasAttachments`.
@shanforge
shanforge force-pushed the shan/issue-61-island-tools-and-research branch from 83880b4 to 143e08b Compare August 21, 2026 08:39
…p stays in its lane

Review of #74 by three independent agents; two of them found the first bug separately.

**A Deep Research send was silently swallowed.** `run` refuses a second run with a bare
guard, but `start` appended the user's question *before* calling it — and neither surface
had a global busy check for Deep Research, because `DeepResearchCoordinator` never
touches `AgentCoordinator`'s run state. So with a run in flight on the other surface the
question landed in the thread with nothing that would ever answer it: no spinner (run
state is per conversation), no progress strip (it belongs to the other conversation), no
error banner, and the composer already cleared. `start` now checks before it appends and
returns nil, and both surfaces put the question back and say why.

This was a regression against main in the other direction too: the main window's input
bar used to read `deepResearchCoordinator.isRunning` globally, so any run disabled Send.
Scoping that — correct in itself — opened the same hole there.

**Stop reached into the other surface.** `cancelWhicheverIsRunning`'s else branch called
`AgentCoordinator.cancel()` unconditionally, and that is unscoped: it kills the one global
task and rejects every pending approval. Pressing "New" on an idle island stopped an
answer streaming in the main window. The decision is now `AskStopTarget`, pure and tested,
with a `nothing` case; both surfaces use it.

**The progress strip and its tests asked different questions.** `hasActivity(in:)` had no
production caller and its doc claimed the strip mounted on it, while the view rebuilt the
rule from `runningConversationID` and `isRunning`. Five assertions pinned a predicate that
shipped nothing. The view asks `hasActivity` now, which absorbed the `.failed` case.

**Deep Research on an empty prompt.** The chip alone routed to research, and a send
carrying only attachments passes the empty check — so a bare file drop appended an empty
bubble and ran the seven-step pipeline on "". The router requires text.

**A call awaiting approval drew two cards** — one row, one pinned — inside a 420pt panel,
only one answerable. `IslandThread` leaves those to the strip.

Both new rules mutation-checked: appending before the refusal, and the unconditional
cancel fallback, each turn their case red.

Part of #61.
#61's box 7 asks that message actions match on both surfaces, and the ground rule is that
a feature is mounted rather than redrawn. Read aloud was the exception nobody noticed: the
main window used AgentReadAloudService, the island owned a bare AVSpeechSynthesizer.

They did not sound the same. The service strips Markdown before speaking, so the main
window said "hello" where the island said "asterisk asterisk hello asterisk asterisk" —
and the island also picked its own voice by a different rule.

Mounting the service removes three things besides the duplication: the island's own
speakingMessageID (a second source of truth for what is playing), the 60-second polling
loop it needed to notice speech had stopped, and its AVFoundation import.

Found reviewing #74's claim to close #61 — the box was reported as delivered, which it
was, by two implementations.

Part of #61.
@shanforge
shanforge changed the base branch from shan/issue-61-land-stranded-stack to main August 21, 2026 09:27
Reported after click-testing: clicking Send closed the island, and so did clicking
outside it while it held a conversation. Three separate defects, all of which had to go
or the symptom survives.

**Sending emptied the island, briefly.** AgentCoordinator.send appends the user message
inside a Task while the composer clears inputText and attachments synchronously, so for a
frame or two after Send there were no rows, no draft and nothing staged — a completely
empty island by every rule here, and an empty island is one they are all allowed to throw
away. The island owns a thread the moment ensureConversation() runs, which is synchronous
and happens first, so that is what counts now: hasMessages becomes hasConversation.

**A click on the island could read as a click somewhere else.** dismissIfClickMissedPanel
converted the event to a screen point and tested panel.frame.contains, and its fallback
for a nil event window returned window-local coordinates treated as screen coordinates.
It also counted clicks inside the attach picker as clicks elsewhere — NSOpenPanel.begin
is non-modal and runs in-process because Logue is unsandboxed, so choosing a file tore
down the island it was for. Decided by window identity now, with panels excluded
wholesale.

**Losing focus buried the island rather than keeping it.** focusLoss returned
sendBehindOtherApps, which dropped the panel to .normal. The panel is non-activating, so
clicking it never restored the level: the island sat under whatever the user had just
clicked with no way back but the shortcut. That is the "it closed" report. The case is
.keep now and the panel is left alone, which also retires Trigger.raise and
raiseChatPanel().

Two smaller things fell out. Esc from another app used to close anything, including a
full conversation — it is now held to the same bar as a stray click, while Esc pressed in
the island still always works. And the transparent-area click consulted only the
conversation while the click monitor also checked attachments, so an island holding a
staged PDF was torn down beside the pill but survived elsewhere; both ask
CommandCenterChatRule.clickOff.

Every dismissal now logs which of the nine paths fired, because an island that vanished
for the wrong reason looks identical to one that vanished for the right one.

Mutation-checked: making escape() unconditional turns three cases red. The
hasConversation wiring is in the SwiftUI view and has no unit coverage — it is a
click-through check.

Part of #61.
The island spelled out attach, web search and Deep Research as three separate glyphs in
its pill while the main window collapsed the same actions into a `+` menu. That is the
last item on #61's list of shared composer parts still being redrawn per surface, and it
left the island with no way to reach tool settings at all.

ComposerPlusMenu is that menu, lifted whole. The island's pill is now logo · + · field ·
mic · send, matching the main window's shape; the mic stays outside the menu on both,
since dictation is a mode you hold rather than arm.

The awkward part is the storage. On macOS a SwiftUI Menu drops Button.action closures and
swallows `.toggle()` against an `@Binding` in its deferred-close pipeline — the main
window's toggles only ever worked because they bind `@AppStorage` directly, and there are
two comments in the input bar saying so. The island's flags were deliberately `@State`,
because sharing the main window's keys let an island send disarm a chip armed over there.
Both constraints hold if the menu takes key *names* and declares its own storage, so the
island got its own pair of keys. `.onAppear` clears them, which keeps the old
dies-with-the-island behaviour rather than letting an armed Deep Research survive to the
next launch.

Tool settings goes through a new AppDelegate.openToolSettings() that activates Logue
first. Posting the notification alone opened the Settings window behind whatever was
frontmost — invisible from an island floating over another app, which reads as the menu
item doing nothing.

Menu copy moved into UICopy.Input; it was typed inline on the main window and the two
surfaces already disagreed about "Deep research" versus "Deep Research".

Tests cover what is testable headlessly: that the island's keys are distinct from the main
window's (the regression fixed once already), that all four are distinct, and that every
menu item has copy. The menu itself is click-through.

Part of #61.
The reported bug survived the previous fix, because that one addressed the paths that
*dismiss* the island and this is not one of them.

TransparentContainerView decided "empty area, put the island away" whenever no SwiftUI
subview claimed the point. That is not the same question as "did the click land outside
the island". The pill's background, its padding, and the gap between the transcript and
the prompt bar are all inside the island and are claimed by no control — so clicking any
of them dismissed it. Clicking the island closed the island.

It now asks whether the point is inside the island's drawn bounds. The hosting view is
pinned to the bottom of a much taller panel and is only as tall as its SwiftUI content,
so its frame is exactly those bounds. Inside: take the click and do nothing with it,
rather than letting it fall through to the app behind and bury us. Outside: unchanged.

The dismissal logging added alongside the earlier fix was at .info, which macOS does not
persist to the log store — which is why reproducing the bug produced an empty log and the
path could not be identified from it. It is .notice now.

Not yet confirmed by click-through; the build is with the user.

Part of #61.
…nu work

Three agents over the new commits. Every finding was walked in the code before being
acted on, and one was refuted: "Tool settings… does nothing with the main window closed"
is false — WindowCloseInterceptor returns false from windowShouldClose and only orders the
window out, so AppRootView and its observer stay alive.

**Two regressions I introduced last round.**

Swallowing inside-island clicks meant never calling super.mouseDown, so the panel stopped
becoming key — which silently disabled Esc, since the local monitor only acts on a key
panel. Clicking the island's own background left it unfocused and unclosable by keyboard.

Mounting the shared AgentReadAloudService then calling stop() unconditionally on
disappear and on New: dismissing the island cut off a reply the *main window* was reading.
Scoped to this island's own rows.

**The X and the click rules disagreed about what a conversation is.** The close button
lives in messagesPanel, gated on visible rows; the rules that refuse to dismiss read
hasConversation, true as soon as a thread exists. A refused Deep Research send lands
exactly in the gap — thread created, nothing in it — leaving an island that no click, app
switch or stray Esc can close and no X to press. One predicate now feeds both.

**A cancelled Deep Research run could clobber the next one.** cancel() releases isRunning
synchronously but the task keeps unwinding until its LLM call returns, which is not
preemptible. A second run starts in that window; the first then writes "Cancelled" over
it, releases isRunning while it is still working, and strips its web-tool override. Every
write from inside execute is now gated on still owning the run generation.

**A refused send restored half of itself.** The prompt and the Deep Research chip came
back; the Search chip did not, so the retry ran without web tools and never said so —
the exact failure that flag's comment was written about. One restore() puts all of it back.

**The island raised toasts it cannot show.** .toastOverlay() is mounted only on the main
window — MessageActions documents this, and I used ToastCenter in the island anyway, so a
swallowed send explained itself into the window behind the pill. It uses the island's own
error banner now.

**Chips were drawn outside the island.** As an .overlay offset -34pt they sat outside the
hosting view's frame on a fresh island, which the panel clips and which hit-testing treats
as "not the island" — so a staged file or an armed mode showed no chip on the one path
where you most need to see it. They are part of the layout now.

Smaller: the dictation callback on a shared singleton is cleared on disappear (the two
in-app panels already did, and leaving ours installed swallowed a later dictation
elsewhere); New no longer keeps the previous question's attachment; Return honours the
same busy check the Send button is disabled on, and the button stops looking live while
disabled; the click monitor carries Sendable facts rather than an NSWindow across the
actor hop; the global Esc monitor no longer applies a chat rule to the recording panel;
Open in Logue stops being logged as the close button.

Esc from another app may still close an island holding only files — deliberate, and now
stated: without a conversation there is no X, so refusing there too would leave it with no
keyboard exit at all. Clicks still spare the files.

Also corrected three comments that had gone stale, and pointed both ModeChip call sites at
UICopy — the island said "Deep Research" while the menu that armed it said "Deep research".

ComposerPlusMenu now picks its keys from a Surface rather than taking them as strings, so
the test asserts the wiring instead of comparing two constants; the old version stayed
green through the regression it named.

Part of #61.
@shanforge
shanforge merged commit 12cd352 into main Aug 22, 2026
2 checks passed
shanforge added a commit that referenced this pull request Sep 4, 2026
#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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Ask Logue part 1: one assistant on both surfaces

1 participant