Ask Logue part 5: tool history and Deep Research on the island - #74
Merged
Conversation
…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
force-pushed
the
shan/issue-61-island-tools-and-research
branch
from
August 21, 2026 08:39
83880b4 to
143e08b
Compare
…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
changed the base branch from
shan/issue-61-land-stranded-stack
to
main
August 21, 2026 09:27
…tools-and-research
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
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
39 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
IslandThreadturns stored messages into rows, so the ordering is the testable part: acard 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 noconversationID— that is what keeps it read-only, because approval belongs to thestrip pinned above the pill where an answer cannot scroll out of reach.
Two defects fell out of reading rows instead of filtering messages:
bubble above the card that explained it. Dropped — unless it is the one being
written, which starts empty and fills in.
Deep Research from the island
AskRouterhas always taken adeepResearchRequestedinput and the island alwayspassed
false, so the branch existed and was unreachable. The toggle binds the sameone-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
time app-wide is exactly what makes a global
isRunninglook sufficient — right upuntil 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 owneroutliving the run because
lastErrordoes.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 showwhen the stored status and the result disagree, were a
private funcinside the mainwindow'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:)—runexpected the user message to bein 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:
awaitingApprovalfilters on the stored statusisRunning(in:)ignores its argumentDeepResearchOwnershipTestsstarts real runs. Nothing in it isasyncand every bodyis synchronous main-actor code, so the spawned task cannot be scheduled before the
defercancels it — the ownership state is set synchronously byrunitself. Makingany case
asyncwould hand the pipeline a turn to start on; there is a note in thefile saying so.
Verification
xcodebuild build— succeeds./scripts/test-no-llm.sh— 1567 tests in 135 suites pass--lint— 0/531 files require formatting--strict— 0 violations in 665 filesDraft 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:
The card appears between the question and the answer, collapsed to one line, and
expands on click.
answer it in the main window instead and the island's card stops offering the
buttons.
— it actually stops.
strip and no spinner. Then the reverse.
Open in Logueon 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.
runrefuses a second run with a bareguard !isRunning, butstartappended the user's question before calling it — andneither surface had a global busy check for Deep Research, because
DeepResearchCoordinatornever touches
AgentCoordinator's run state. So with a run in flight on the other surfacethe 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
mainin the other direction too. The main window's input barused to read
deepResearchCoordinator.isRunningglobally, so any run disabled Send.Scoping that — correct in itself, and the point of this PR — opened the same hole there.
startnow checks before it appends and returnsnil; both surfaces put the questionback and say why.
Major — fixed
cancelWhicheverIsRunning'selsebranchcalled
AgentCoordinator.cancel()unconditionally, and that is unscoped: it kills theone 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
nothingcase — and both surfaces use it.hasActivity(in:)hadno production caller, and its doc claimed the strip mounted on it while the view rebuilt
the rule from
runningConversationIDandisRunning. Five assertions pinned a predicatethat shipped nothing. The view asks
hasActivitynow, which absorbed the.failedcase.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.inside a 420pt panel, only one of them answerable.
IslandThreadleaves those to thestrip.
service strips Markdown before speaking; the island's private
AVSpeechSynthesizerdidnot, so the main window said "hello" where the island said "asterisk asterisk hello
asterisk asterisk". Mounting the service also removed a duplicate
speakingMessageID, a60-second polling loop, and an
AVFoundationimport.Tests
Both new rules mutation-checked — each reproduces the exact reported bug:
One existing case was strengthened:
resultIsNotARowasserted onlyrows.count == 1, soemitting 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
--strictclean.On
Closes #61The tests reviewer walked all eight boxes and found them delivered. Two deviations from
the letter of the scope, both deliberate, neither leaving functionality missing:
+menu shared". The main window has a popover (including aTool 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.
message list; only the tool card is genuinely mounted. Documented in the code.
Also worth knowing before merge:
Closes #61is inert while this PR's base isshan/issue-61-land-stranded-stack. GitHub only acts on a closing keyword when the PRmerges 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:
told it is busy, and your question and chip are still there.
answer keeps streaming.
agent turn, not an empty research run.
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.sendappends the usermessage inside a
Taskwhile the composer clearsinputText/attachmentssynchronously.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 andhappens first, so that is what counts now (
hasMessages→hasConversation).A click on the island could read as a click somewhere else.
dismissIfClickMissedPanelconverted the event to a screen point and testedpanel.frame.contains; its fallback for a nil event window returned window-localcoordinates treated as screen coordinates. It also counted clicks inside the attach
picker as clicks elsewhere —
NSOpenPanel.beginis non-modal and runs in-process becauseLogue 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.
focusLossreturned.sendBehindOtherApps, which dropped the panel to.normal. The panel is.nonactivatingPanel, so clicking it never re-activates Logue and never restored thelevel — 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
.keepnow, which also retiresTrigger.raiseandraiseChatPanel().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:
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.
TransparentContainerViewdecided "empty area, put the island away" whenever noSwiftUI 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 bugproduced an empty log and told me nothing. It is
.noticenow.One
+menu, mounted by both surfacesThe 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 persurface, 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 onboth.
The awkward part is storage. On macOS a SwiftUI
MenudropsButton.actionclosures andswallows
.toggle()against an@Bindingin its deferred-close pipeline — the mainwindow's toggles only ever worked because they bind
@AppStorage, and two comments in theinput bar say so. But the island's flags were deliberately
@State, because sharing themain 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;
.onAppearclears them, keeping the old dies-with-the-island behaviour."Tool settings…" goes through a new
AppDelegate.openToolSettings()that activates Loguefirst — 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
--strictclean.New: the
clickOff/escapematrix, a case pinning that a thread with no rows yet is stillcontent, and the
+menu's key isolation (all four one-shot keys distinct).Mutation-checked: making
escape()unconditional turns three cases red. Stated plainly —the
hasConversationwiring lives in the SwiftUI view and has no unit coverage; it ischeck 1 below.
Click-through for these two
answer arrives.
main window → stays; Esc in Safari → stays; click in the island then Esc → closes; X →
closes; hotkey → closes.
+→ "Add photos & files" on an empty island, click inside the open panel → the islandsurvives (it used to die mid-pick).
+menu opens with Logue active and with Safari frontmost; toggles check and thechips 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.