Skip to content

feat(mcode-island): show every live session when the mouse rests on the pill - #4

Merged
antianqi merged 16 commits into
feat/substep-progressfrom
feat/multi-session-pill
Oct 24, 2026
Merged

antianqi merged 16 commits into
feat/substep-progressfrom
feat/multi-session-pill

Conversation

@antianqi

Copy link
Copy Markdown
Owner

Stacked PR. The base is feat/substep-progress (the branch of
MiniMax-AI#62). The diff here is only the multi-session list; everything else
in the branch is already on the base. When MiniMax-AI#62 lands, retarget this to
main.

What this adds

The pill used to show exactly one session. Get-LatestSessionFile
scans every *.jsonl under the session store and takes the most
recently modified file, so the pill meant "whoever touched a log
last"
. A background session could take the pill away from the one you
were watching, and there was no way to see the others.

Now the pill carries a list with a lifecycle:

  detected active   ->  running
  the agent stops   ->  waiting for you
  you click it      ->  dismissed

Hover the pill and it expands a list under it — one row per session,
with a state dot (blue = running, amber = waiting), the session's
first line, and its state. Clicking a row dismisses it and switches the
CLI to that session.

The two rules that are easy to get backwards

"Is it active" is decided by the log's mtime, not by the last
message.
A conversation that stopped weeks ago still has a toolCall
as its last line, so the message type says "running" while it has not
moved in a month. With the message type alone, one pass tracked 80+
sessions.

"Is it running or waiting" is decided by the last message. The
reverse would mislabel a session that is busy with a 40-second tool (no
log writes for 40 seconds) as "waiting", and no window size fixes that.

last message state
toolCall / toolResult / thinking running
assistant with plain text waiting — it said its piece
ask_user waiting — the ball is in your court
user running — you just asked

A list, not a query

A session that stops stays in the list as waiting until you dismiss
it. Nothing expires on a timer, so a conversation you were not looking
at does not silently vanish, and one you already dealt with does not
come back.

The list lives in sessions.json, not status.json: status.json is
written by both the detector and the agent side, and the agent side
overwrites the whole file, so a list in there would be erased by the
next tool call. sessions.json is also the persistence — the detector
restarts often, and a list that only lived in memory would lose the
"waiting" items, which are the ones that matter most.

The one place a time window is still used: at startup, sessions touched
in the last 30 minutes are seeded. Without it, a session that finished
just before the detector restarted would never produce the
"running → waiting" transition, so its waiting state would never be
seen. After startup the state machine does the work and no timer is
involved.

Titles come from the first user message with the <system-reminder>
injection block stripped — otherwise the row shows a system prompt
instead of what you asked. A compacted session (one whose original
prompt was replaced by a compaction summary) falls back to the agent's
first line.

The "you clicked it" channel is ack.signal, reusing the pattern the
plugin already uses for the show signal. A dismissed session comes
back only after it finishes a round and starts a new one.

The list is drawn inside the pill's own window

A separate top-level window was tried first and measured: it was
created, positioned correctly and hit-testable (WindowFromPoint
returned it), yet it painted nothing — of 1053 sampled pixels inside
its rect, only 39 had the panel colour and the rest was whatever was
behind it. The same code painted fine in isolation, so the problem was
in the widget's context, not in the window.

Drawing in the pill's own window sidesteps it: the same rendering,
compositing and hit-testing path the pill already uses, which is proven
on this machine. The cost is that the window gets taller — row 0's
height is pinned to the pill's height, so the pill itself does not move
when the list opens. And because the list is in the same window, the
mouse can move from the pill into the list without ever leaving the
window: no gap, no grace period for the cursor to fall into.

Test evidence

scripts/test-session-tracker.ps1   20 checks, all passing
scripts/test-substep-progress.mjs  125 pass, 0 fail, 1 skipped (platform)

The tracker harness assembles the scope the tracker lib expects (the
detector's constants, Get-LastJsonLine, Test-IsAskUser, the store
lock) against a fixture session tree, so the assertions run against
the real functions rather than a re-implementation of them.

7 negative-injection cases, each breaking one clause of the contract
in the real source; all 7 turn the suite red:

case result
T1 the mtime check removed (every session enters) RED
T2 the phase mapping inverted RED
T3 the title does not strip the reminder block RED
T4 dismissal does not remove the entry RED
T5 ack.signal is not consumed RED
T6 the list is not restored on restart RED
T7 the cycle transition is not detected RED

Three real bugs were found by writing those checks, all of them silent
in production:

  • the detector called Restore-SessionTracker while the lib defined
    Restore-SessionsFile, so the list was lost on every detector restart
    and the try/catch swallowed it as SESSIONS INIT FAIL;
  • the mtime comparison mixed UtcNow with GetLastWriteTime, so the
    age came out negative and the "too old" check never fired;
  • a dismissed session was removed from the map, so the
    waiting → running transition that should bring it back was never
    detected.

Verified on the target machine by driving the real mouse: hovering the
pill expands the list, and clicking a row writes ack.signal, removes
the row and opens the session. The list's presence is asserted by
sampling pixels inside the window rect and checking the panel colour,
not by reading the log — a window that claims to be visible but paints
nothing is exactly the failure this feature hit, and a log line would
have said "shown".

Design compliance

no credentials none added
no network none added; the tracker reads the local session store only
no telemetry none added; island.log unchanged
no third-party services no new dependencies
cross-platform no host-absolute paths introduced
encoding the new lib and the new test carry a UTF-8 BOM (they contain non-ASCII, and Windows PowerShell 5.1 decodes a BOM-less script with the system ANSI codepage)
file layout sessions.json is new and detector-owned; status.json's schema is unchanged
visual change the pill itself is untouched — same size, same colours, same rendering. The list only appears while the mouse rests on it, and the window returns to its original height when the mouse leaves

@antianqi
antianqi force-pushed the feat/multi-session-pill branch 2 times, most recently from cf64fce to f835062 Compare October 23, 2026 07:00
The pill showed ONE session: Get-LatestSessionFile scans every
*.jsonl under the session store and takes the most recently modified
file. So the pill meant "whoever touched a log last" -- a background
session could take the pill away from the one you were actually
watching, and there was no way to see the others at all.

This adds a session list with a lifecycle:

    detected active  ->  running
    the agent stops  ->  waiting for you
    you click it     ->  dismissed

Two rules that are easy to get backwards, and both are pinned by the
tests:

  "Is it active" is decided by the log's mtime, not by the last
  message. A conversation that stopped weeks ago still has a toolCall
  as its last line, so the message type says "running" while it has
  not moved in a month. With the message type alone, one pass tracked
  80+ sessions.

  "Is it running or waiting" is decided by the last message. The
  reverse would mislabel a session that is busy with a 40-second tool
  (no log writes for 40 seconds) as "waiting", and no window size
  fixes that.

So a session enters the list when its log was touched in the last
minute, and its state is read from the last message:

    toolCall / toolResult / thinking   ->  running
    assistant with plain text          ->  waiting   (it said its piece)
    ask_user                           ->  waiting   (the ball is in your court)
    user                               ->  running   (you just asked)

The list has a lifecycle, not a query: a session that stops stays in
the list as "waiting" until you dismiss it. Nothing expires on a
timer, so a conversation you were not looking at does not silently
vanish, and one you already dealt with does not come back.

The list lives in sessions.json, not status.json. status.json is
written by both the detector and the agent side (notify-island.ps1),
and the agent side overwrites the whole file -- a list in there would
be erased by the next tool call.

sessions.json is also the persistence. The detector restarts often
(the per-tick guard, autostart, crashes), and a list that only lived
in memory would lose the "waiting" items, which are the ones that
matter most. It is read back at startup.

The one place a time window is still used: at startup, sessions that
were touched in the last 30 minutes are seeded. Without it, a session
that finished just before the detector restarted would never produce
the "running -> waiting" transition, so its "waiting" state would
never be seen. After startup the state machine does the work and no
timer is involved.

Titles come from the first user message, with the <system-reminder>
injection block stripped -- otherwise the row shows a system prompt
instead of what you asked. A compacted session (one whose original
prompt was replaced by a compaction summary) falls back to the agent's
first line, which at least says what the session was about.

The "you clicked it" channel is ack.signal, reusing the pattern the
plugin already uses for the show signal: the widget writes the id, the
detector consumes it and drops the session. A dismissed session comes
back only after it finishes a round and starts a new one.

## Test evidence

  scripts/test-session-tracker.ps1: 20 checks, all passing, wired
  into the behavioral suite (125 pass, 0 fail, 1 skipped).

  7 negative-injection cases, each breaking one clause of the contract
  in the real source; all 7 turn the suite red:

    T1 the mtime check removed (every session enters)   -> RED
    T2 the phase mapping inverted                      -> RED
    T3 the title does not strip the reminder block      -> RED
    T4 dismissal does not remove the entry              -> RED
    T5 ack.signal is not consumed                      -> RED
    T6 the list is not restored on restart              -> RED
    T7 the cycle transition is not detected            -> RED

  Three real bugs were found by writing those checks, all of them
  silent in production:

    - the detector called Restore-SessionTracker while the lib
      defined Restore-SessionsFile, so the list was lost on every
      detector restart and the try/catch swallowed it as
      "SESSIONS INIT FAIL";
    - the mtime comparison mixed UtcNow with GetLastWriteTime, so
      the age came out negative and the "too old" check never fired;
    - a dismissed session was removed from the map, so the
      waiting -> running transition that should bring it back was
      never detected.

## Design compliance

  no credentials      : none added
  no network          : no network calls added; the tracker reads
                        the local session store only
  no telemetry        : none added; island.log unchanged
  no third-party svcs : no new dependencies
  cross-platform      : no host-absolute paths introduced
  encoding            : the new lib and the new test carry a UTF-8
                        BOM (they contain non-ASCII, and Windows
                        PowerShell 5.1 decodes a BOM-less script with
                        the system ANSI codepage)
  file layout         : sessions.json is new and detector-owned;
                        status.json's schema is unchanged
@antianqi
antianqi force-pushed the feat/multi-session-pill branch from f835062 to defad36 Compare October 23, 2026 07:05
… pill

Hovering the pill expands a list under it: one row per session, with a
state dot (blue = running, amber = waiting for you), the session's first
line, and its state. Clicking a row dismisses it and switches the CLI to
that session.

The list is drawn INSIDE the pill's own window, not in a second window.

A separate top-level window was tried first and measured: it was
created, positioned correctly, and hit-testable (WindowFromPoint
returned it), yet it painted nothing -- of 1053 sampled pixels inside
its rect, only 39 had the panel colour and the rest was whatever was
behind it. The same code painted fine in isolation, so the problem was
in the widget's context, not in the window. Drawing in the pill's own
window sidesteps it entirely: the same rendering, compositing and
hit-testing path the pill already uses, which is proven on this
machine.

The cost is that the window gets taller. Row 0's height is pinned to
the pill's height, so the pill itself does not move when the list
opens -- the list grows below it. And because the list is in the same
window, the mouse can move from the pill into the list without ever
leaving the window: no gap, no grace period for the cursor to fall
into.

The rows need three grid columns (dot / title / state label). Without
them the children overlap and a long title covers the label.

Clicking a row has to mark the event handled: the window carries the
drag handler, and without that a click on a row would be read as the
start of dragging the pill.

Dismissing writes ack.signal, which the detector consumes. Switching
runs `mcode --session <id>` in a new Windows Terminal tab, falling back
to conhost where wt.exe is not on PATH.

## Test evidence

  Verified on the target machine by driving the real mouse: hovering
  the pill expands the list with the live sessions, and clicking a row
  writes ack.signal, removes the row, and opens the session.

  The list's presence is asserted by sampling pixels inside the
  window rect and checking the panel colour, not by reading the log:
  a window that claims to be visible but paints nothing is exactly the
  failure this feature hit, and a log line would have said "shown".

  The behavioral suite is unchanged by this commit (125 pass, 0 fail);
  the session tracker checks live in the previous commit.

## Design compliance

  no credentials      : none added
  no network          : none added
  no telemetry        : none added
  no third-party svcs : no new dependencies
  cross-platform      : no host-absolute paths introduced
  encoding            : the file keeps its UTF-8 BOM
  visual change       : the pill itself is untouched -- same size, same
                        colours, same rendering. The list only appears
                        while the mouse rests on it, and the window
                        returns to its original height when it leaves.
@antianqi
antianqi force-pushed the feat/multi-session-pill branch from defad36 to 37652f0 Compare October 23, 2026 07:20
@antianqi

Copy link
Copy Markdown
Owner Author

Two fixes found by driving the real mouse

The first version of the list had two ways to not open. Both were found
by moving the actual pointer at the pill on the target machine, not by
reading the code.

MouseEnter alone is not enough. The list opened on MouseEnter,
which only fires when the pointer crosses into the window. If the
widget starts while the pointer is already resting on the pill — the
pointer has not moved, so no enter event ever happens — the list never
appears at all. The MouseMove handler now opens it when the pointer is
on the pill and the list is closed. It is skipped while a drag is in
progress, so dragging the pill does not pop the list open under the
cursor.

A pending hide timer could close the list right after it opened.
Show-SessionList is reached from three places (MouseEnter, the
MouseMove fallback, and a direct call), and only one of them
cancelled the pending 300 ms hide timer. The other two left it
running: the list expanded, the window grew, and then the timer fired
and shrank it back — a visible flicker, and the window ending up at
its old height.

Show-SessionList now cancels the timer itself, so every path into it
is covered.

A note on how this was verified

The first version of the check was a log line that said "session list
shown" — and the window was still 70px tall. The log and the screen
disagreed, and the log was the one that was wrong: the list had been
expanded and then collapsed again by the timer race above.

The check now reads the window's actual height, and the log line
reports the height it set, so "shown" and "actually visible" cannot be
confused again.

Verified on the target machine: the pointer rests on the pill, the
window grows from 70 to 168, and the list shows the live sessions with
their state.

The 5h-usage fix landed on the base branch. Merging it in keeps this PR's diff limited to the multi-session feature: both branches appended a section to scripts/test-substep-progress.mjs, so the conflict was resolved by keeping BOTH sections -- the multi-session tracker (10) and the 5h-usage regression (11).
@antianqi
antianqi force-pushed the feat/substep-progress branch from f504326 to d3115f3 Compare October 23, 2026 08:07
@antianqi
antianqi force-pushed the feat/multi-session-pill branch from cecbd29 to 7ab8726 Compare October 23, 2026 08:07
The expanded list showed one line per session: a status dot, the first
question, and the state. That is enough to recognise a session but not
to tell what it is doing RIGHT NOW -- "the detector follows whichever
session wrote last" is the whole reason this list exists.

The layout now follows the expanded-state reference the user pointed at:
two lines of text per session and a timestamp on the right.

    [dot]  first question.............  just now
           last thing said...........  running
    ---------------------------------------------
    [dot]  another question...........  41 min ago
           running <tool>............  running

What each piece costs:

  first line   the session title (already there)
  second line  the last thing said, or the tool being run
  right column the last activity time, and the state

The second line is the one that carries information. Looking at the
first question alone, a session that is thinking and a session that is
stuck on one long tool look identical.

Data plumbing

The tracker already had the last message in memory -- Get-LastJsonLine
reads it and Get-SessionPhase parses its content -- so the summary is
free: no extra IO, no extra file reads.

  preview    the text of the last message (agent's or user's)
  lastTool   the tool name of the last message
  lastAtMs   the last activity time (the log's mtime)

Three rules for what a summary may be, because "the last thing said" is
not the same as "the last thing in the file":

  - a toolResult's text is tool output (code, logs), not conversation.
    It never becomes a summary.
  - a toolCall has no text; the tool name stands in for it.
  - a toolResult has no toolCall in its content, but it DOES carry
    message.toolName. Without that fallback the second line was empty
    for every session whose last line was a tool result -- which is
    most of the time while an agent is working.

The tracker stores the raw values and the widget does the formatting.
Changing the layout must not require touching the detector.

One existing bug fixed on the way

The tracker only marked itself dirty when the STATE changed. The
summary and the timestamp change without the state changing (an agent
runs three tools in a row: still running, but the summary and the
clock move). The in-memory entry was right and the file on disk was
not, so the list rendered an empty second line and a frozen timestamp.
Now any change to the content marks the file dirty.

Validation

  - scripts/test-session-tracker.ps1 grew from 20 to 37 checks: the
    summary, the tool name, the timestamp, the file round-trip, the
    restart, and the same-state-but-changed-content case.
  - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform) on
    Windows PowerShell 5.1, which is what CI runs.
  - Negative injection, 7 mutations, each one expected to turn a
    specific check red:
      N1 drop the toolResult exclusion      -> "toolResult's text is not
                                                a summary" goes red
      N2 stop recording lastAtMs            -> the two timestamp checks
                                                go red
      N3 stop restoring preview             -> "the preview survives a
                                                restart" goes red
      N4 stop recording lastTool            -> the two tool-name checks
                                                go red
      N5 make the summary always empty      -> the four summary checks
                                                go red
      N6 revert the dirty check             -> "a changed tool name with
                                                an unchanged state still
                                                reaches the file" goes red
      N7 drop the toolName fallback         -> "a toolResult's tool name
                                                is recorded" goes red
    Every mutation was applied on the bytes, not on a decoded string:
    a text-level round trip through the wrong code page turned the file
    from 16132 into 12651 characters and made every anchor miss.
  - Live, on this machine: the deployed plugin was restarted, the
    mouse rested on the pill, and the screenshot shows the layout
    above -- three sessions, two lines each, "just now / 41 min ago /
    15:39" on the right, "running" under it, a rule between the rows.
    The first row's second line is the agent's own last sentence; the
    second row's is "running grep".

Test evidence

  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped
  - scripts/test-session-tracker.ps1     -> 37 pass, 0 fail
  - 7 negative injections -> each turned its own check red; the suite
    was green again after every revert

Design compliance

  - The pill itself is unchanged: same window, same width, same drag
    handling, same 400ms poll. The list still grows out of row 1, so
    the pill does not move while the list opens.
  - The click handler moved to the row's outermost element, so a click
    anywhere in the row dismisses that session and nothing fires
    twice.
  - No credentials, no network, no telemetry, no third-party service.
    The summary comes from the session log the detector already reads.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
The list's first line was the first question of the session, cut at 40
characters. That is not a name: half the sessions start with a URL, and
the rest start with a sentence that means nothing once it is cut ("go
on and finish the capability list panel. first look at where the
static scanner stands.").

The runtime already keeps a title for every session:

  ~/.minimax/v2/sqlite/runtime-state.sqlite
  local_runtime_sessions.title

413 of the 417 sessions have one, and they are distilled subject lines
("fix the MCode 0.6.13 update failure", "what the PR 62 review says",
"how mcode's miniapp works"). That is what the session is called.

Reading it

PowerShell has no SQLite reader and the plugin ships no binaries (the
Skill-only, zero-npm-dependency rule). So the read borrows a node that
is already on the machine:

  - node:sqlite is a BUILT-IN module of Node 22.5+, not a dependency;
  - the CLI install ships one: ~/.minimax-code/runtime/node-*/node.exe;
  - the desktop app is Electron, and ELECTRON_RUN_AS_NODE=1 turns it
    into a node (verified: node 24.18.0, node:sqlite available).

The runtime is looked up in that order: node on PATH, then the CLI's
own, then the desktop app as node. If none of them works, the whole
feature is off and the list falls back to the first question -- exactly
what it did before. The same shape as scripts/lib/Get-5hUsage.ps1: an
optional lib, and when the lib is not there the chain is simply closed.

The read is read-only and does not touch the app's data. It runs once a
minute, not once a tick: the titles barely change, and opening a 1.9 GB
database is not something to do every second. Measured on this
machine: 391 ms for the first read, then cached.

Two things that had to be right

  - The temporary script is written as .cjs. A package.json with
    "type": "module" (there is one in the user profile) makes node load
    a .js file as an ES module, and require() then fails with "require
    is not defined in ES module scope".
  - The database path is the second argv, not the first: argv[1] is the
    script's own path.

Fallback

The runtime title is preferred; the first question stays the fallback.
The fallback is not a formality: the runtime keeps no title for four
sessions, and a machine without a usable node keeps none at all.

Validation

  - scripts/test-session-tracker.ps1 grew from 37 to 44 checks. The new
    ones build a REAL sqlite database with node:sqlite (a temporary one,
    pointed at through MCODE_RUNTIME_STATE_DB) and assert: the title
    comes back, a session that is not in the database has no title, an
    empty title stays empty, the list prefers the runtime title over the
    first question, and both fallbacks land on the first question.
  - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform).
  - Negative injection, 10 mutations, each expected to turn a specific
    check red. Three of them are new:
      N8 the display name stops asking for the runtime title
         -> "the list prefers the runtime title" goes red
      N9 the fallback to the first question is removed
         -> "no runtime title falls back to the first question" and
            "a missing database falls back to the first question" go red
      N10 the title map is never read
          -> "the title comes back from the runtime database" goes red
    The seven earlier ones (preview, timestamp, tool name, the dirty
    check) still hold.
  - Live, on this machine: the list now reads
      "look at the new reply on MiniMax-Code-Plugins PR 62"
      "how mcode's miniapp works"
    where it used to read a cut-off first question.

Test evidence

  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped
  - scripts/test-session-tracker.ps1     -> 44 pass, 0 fail
  - 10 negative injections -> each turned its own check red; the suite
    was green again after every revert

Design compliance

  - The plugin still ships no binaries and no npm dependencies.
    node:sqlite is built into the node it borrows; nothing is bundled.
  - No credentials, no network, no telemetry, no third-party service.
    The title is read from a local database the host already keeps, and
    the read is read-only.
  - The feature degrades to the previous behaviour instead of failing:
    no node, no database, no title in the database, a locked database,
    a timeout -- every one of those leaves the list exactly as it was.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
… paths

The ubuntu CI job failed on the previous commit, and it failed twice for
two different reasons. Both were the same mistake: building a path out
of an environment variable that does not exist everywhere.

First: $env:USERPROFILE. Windows has it, the ubuntu runner does not,
and Join-Path does not accept an empty path. Two places built a path
out of it -- the CLI runtime lookup (~/.minimax-code/runtime) and the
runtime database (~/.minimax/v2/sqlite). Both were outside the try
blocks, so the exception escaped and took the tracker with it.

Then: $env:TEMP. Linux PowerShell has TMPDIR, not TEMP. The temporary
script for the node read went through Join-Path $env:TEMP, threw, and
the catch swallowed it -- so the read returned nothing and the list
silently fell back to the first question. That one is nastier: nothing
crashed, the feature just quietly did not work, and the failure
reported by the suite was "the title came back empty", a long way from
"your temp directory variable is missing".

The fix is the same shape in both places:

  - a home-directory lookup that accepts USERPROFILE or HOME and
    returns an empty string instead of throwing;
  - the temporary directory comes from .NET's GetTempPath(), which
    knows the right path on both platforms;
  - every function in the title path -- Get-NodeCommand,
    Read-RuntimeTitles, Get-RuntimeSessionTitle -- swallows its
    exceptions, because a missing title is not an error, and the
    detector's main loop calls this every tick.

A missing home directory, a missing temp directory, no node, no
database, a locked database, a timeout -- all of them now mean the same
thing: the list shows the first question, exactly as it did before the
feature existed.

Validation

  - Two new checks in scripts/test-session-tracker.ps1:
      * with USERPROFILE, HOME and MCODE_RUNTIME_STATE_DB cleared, the
        three title functions run without throwing;
      * with $env:TEMP cleared but a real database pointed at, the
        title STILL comes back. The second one is the one that locks
        this bug down: the first attempt at this fix only handled the
        home directory and the ubuntu job still failed.
  - The suite on this machine: 46 pass, 0 fail for the tracker, and
    133 pass, 0 fail, 1 skipped (platform) for the behavioural suite.
  - Reproduced locally: a child PowerShell with USERPROFILE, HOME and
    TEMP all cleared runs the title path and comes back with no
    exception and no title.

Test evidence

  - scripts/test-session-tracker.ps1     -> 46 pass, 0 fail
  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - The title is still an optional extra. Every failure mode leaves the
    list exactly as it was before the feature existed.
  - No new dependency: the same node that was already borrowed, the
    same read-only database, no binaries, no network, no credentials.
@antianqi
antianqi force-pushed the feat/multi-session-pill branch from d6959ca to c2f6ff5 Compare October 23, 2026 10:17
The list said "running" for a session that had been quiet for almost an
hour.

The cause is a freeze, not a wrong reading. The tracker decides whether
a session is running from the type of its last message: a toolCall or a
toolResult means the agent is busy. That is right while the agent works
and wrong the moment it stops -- after the last tool runs, the last
message stays a toolCall forever.

On top of that, the update loop skips a session whose log has not
changed:

  if ($entry -and $entry.MTime -eq $g.MTime) { continue }

So once the agent stopped, the phase was never recomputed again. It
froze on "running" and stayed there until something else happened to
touch the file. The detector's own log shows it:

  18:13:44  session restarted: mvs_cd09012264b5 (cycle 7)
  19:31:47  session restarted: mvs_cd09012264b5 (cycle 8)

Seventy-eight minutes apart, and in between the session was shown as
running while it was doing nothing.

The fix

"Running" now needs a liveness witness as well as a message type. If a
running session has been quiet for more than five minutes it is not
running, it is stopped, and it says so:

  running   the agent is busy (blue)
  waiting   the agent is waiting for you (amber)
  stopped   the agent is gone; the last message was a tool call that
            never came back (grey)

Five minutes is long enough that a slow tool or a long model turn is
not mistaken for a dead session, and short enough that nobody waits an
hour to see the state change.

The downgrade happens in the branch that used to `continue` -- the one
that runs when the log has not changed. That is the point: the state
has to be able to change without the log changing.

"Waiting" is untouched: the ball is in the user's court, and a session
the user has not answered for half an hour is still waiting.

"Stopped" ends a round just like "waiting" does, so a session that
comes back to life counts as a new round and the dismissal constraint
is lifted -- the same rule that already applied to waiting.

Validation

  - scripts/test-session-tracker.ps1 grew from 46 to 52 checks. The new
    ones stop a session (its log's timestamp is moved ten minutes into
    the past), let the tracker run twice -- the second run is the one
    that takes the "the log has not changed" path -- and assert that it
    is no longer reported as running, that it is reported as stopped,
    that a waiting session stays waiting, and that a stopped session
    that comes back counts as a new round.
  - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform).
  - Negative injection, 3 mutations:
      N11 remove the liveness downgrade   -> "a session that stopped
           for ten minutes is no longer running" goes red
      N12 stretch the window to ten hours  -> the same checks go red
      N13 stop counting "stopped" as the end of a round
           -> "a stopped session that comes back counts as a new
              round" goes red
  - Live, on this machine: the deployed copy was temporarily given a
    five-second window, and the pill then showed the stopped state
    with a grey dot and the word "stopped" next to a session whose
    log was quiet. The window was restored to five minutes afterwards.

Test evidence

  - scripts/test-session-tracker.ps1     -> 52 pass, 0 fail
  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped
  - 3 negative injections -> each turned its own check red; the suite
    was green again after every revert

Design compliance

  - The list keeps its three states: nothing is removed from it, so a
    session that stopped stays visible and says why it is idle.
  - Sorting follows the same order: running first, then waiting, then
    stopped.
  - No new dependency, no network, no credentials. The change is one
    threshold and one extra state.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
The pill could say "waiting for approval" and "done"; the list could
say "running", "waiting" and "stopped". Two different vocabularies for
the same thing, and they disagreed: when the user answered a question,
the pill said "waiting for the user" (the last message was role=user,
which the detector reads as idle) while the list said "running" (the
tracker read role=user as "the agent is about to move").

The user could see one session showing two different things at once, in
two places on the same screen.

The fix

The list now produces the pill's state vocabulary and nothing else:

  working   the agent is busy          -> running
  thinking  the agent is thinking       -> thinking
  waiting   the agent is waiting for approval -> waiting for approval
  idle      the agent is done, or the user just spoke -> waiting for you
  done      a tool finished             -> done
  error     a tool failed               -> error

Same words, same colours, same meaning in both places. The tracker
reads the message the same way the detector does -- the same scan for
toolCall / thinking / text, the same ask_user check -- and the list
just renders the result.

The "stopped" state stays: it is not part of the pill's vocabulary, and
it is not a message type. It is what a session looks like when its last
message says "running" but nothing has happened for five minutes.

The round-end bookkeeping follows the new vocabulary: idle, waiting,
done, error and stopped all end a round, and working or thinking starts
the next one. That is what releases a dismissed session.

Validation

  - scripts/test-session-tracker.ps1 grew from 52 to 57 checks. The new
    ones pin the whole vocabulary: toolCall -> working, assistant text
    -> idle, ask_user -> waiting, user just spoke -> idle, tool result
    -> done, failed tool -> error, thinking only -> thinking. Plus the
    liveness fallback for a thinking session, which was not covered
    before: a session stuck in thinking used to stay "thinking" for
    ever, the same class of bug as the one that was "running" for ever.
  - The suite on this machine: 57 pass, 0 fail for the tracker, and
    133 pass, 0 fail, 1 skipped (platform) for the behavioural suite.
  - Negative injection, 5 mutations:
      N14 a tool result is no longer "done"
          -> "a tool result means done" goes red
      N15 the user having just spoken is "running" again (the old list
          semantics) -> "the user just spoke means waiting for you"
          goes red
      N16 assistant text becomes "waiting" again
          -> "assistant text means waiting for you" goes red
      N17 the thinking state is gone
          -> "an assistant that only thinks is thinking" goes red
      N18 the liveness fallback no longer knows about thinking
          -> "a thinking session that stopped is downgraded" goes red
  - Live: the list showed "running" in blue and "done" in green, with
    the agent's own last sentence as the second line.

Test evidence

  - scripts/test-session-tracker.ps1     -> 57 pass, 0 fail
  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - The pill's own behaviour is untouched. This change only makes the
    list agree with it.
  - The states file keeps its "phase" field name, so a sessions.json
    written by an older detector still loads; the old "running" value is
    still understood.
  - No new dependency, no network, no credentials.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
The list said "done" for any session whose last message was a tool
result. Read in the list -- where one row is one session -- that says
"this session is finished". It is not: the tool finished, the round did
not. The agent still has to write its next message, or call the next
tool, or hand control back.

So the pill and the list were answering two different questions and
showing it in the same words. The pill's "done" is a flash of feedback
about the last action ("done" plus "Read", "Edited"); the list's "done"
sat there for as long as the session did, saying something that was not
true.

A tool result -- successful or failed -- now means "working". The round
is still going. The list says "running", the agent's own last sentence
or the tool it is calling fills the second line, and the only thing
that ends a round is the agent finishing its turn, which is "waiting for
you".

That also makes the two places agree without making them identical. The
pill still flashes "done" when a tool finishes -- it is answering "what
just happened". The list answers "what state is this session in", and
a session that just ran a tool is running.

The "stopped" state is unaffected: it is not a message type either, it
is what a session looks like when its last message says "running" but
nothing has happened for five minutes.

Validation

  - scripts/test-session-tracker.ps1 grew from 57 to 59 checks. The new
    ones pin the tool result in both message formats: a tool result in
    messages.jsonl and the same event in ledger.jsonl both mean
    "working", not "done" and not "idle".
  - The suite on this machine: 59 pass, 0 fail for the tracker, and
    133 pass, 0 fail, 1 skipped (platform) for the behavioural suite.
  - Negative injection, 6 mutations:
      N14 a tool result counts as the end of the round
          -> "a tool result means running" goes red
      N19 the same rule broken in the ledger branch
          -> "a ledger tool result means running" goes red
      N15 the user having just spoken is "running" again
          -> "the user just spoke means waiting for you" goes red
      N16 assistant text becomes "waiting" again
          -> "assistant text means waiting for you" goes red
      N17 the thinking state is gone
          -> "an assistant that only thinks is thinking" goes red
      N18 the liveness fallback no longer knows about thinking
          -> "a thinking session that stopped is downgraded" goes red
  - Live: the list shows running, running and waiting for you. The pill
    above it still says "done" for the instant after a tool finishes.

Test evidence

  - scripts/test-session-tracker.ps1     -> 59 pass, 0 fail
  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - The pill's own behaviour is untouched. It still flashes "done" after
    a tool, which is what a user wants to see at that moment.
  - The list keeps the session-level vocabulary only: running, thinking,
    waiting for approval, waiting for you, stopped.
  - No new dependency, no network, no credentials.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
Clicking a session in the list opened a new window running
"mcode --session <id>". The session was already running somewhere, so
there were now two terminals for one session: the old one, still going,
and the new one, just started. The ask was to bring back the window that
was already there.

How the session is found

Windows Terminal names its tabs "<state> | <session name> | MCode" --
the name is set by mcode itself through an ANSI OSC 0 sequence. The
pill's own window cannot do the switching: Win32's SetForegroundWindow
only activates the window's CURRENT tab, which is not the tab you want.
UI Automation can enumerate the tabs and select the right one. Verified
by reading the selection back after switching: the target tab was the
selected one.

The tab's name is whatever the session was called at some moment, while
the title the tracker holds is the newest one (the runtime keeps it in
its SQLite). They drift apart. So the tracker now records every title a
session has had -- the newest one and the first user message, which is
usually the name the tab is showing -- and the widget matches against
all of them.

What happens when it does not match

Nothing opens a second terminal. The Terminal window is brought to the
front instead and the user finds the tab themselves. A name that no
longer matches should cost the user one glance, not a duplicate
process. A new terminal is opened only when there is no Terminal window
at all to bring forward -- the desktop app's own embedded terminal is
like that, and there opening one really is the only way to reach the
session.

The "ack" that dismisses the row from the list is unchanged: clicking
still means "I am dealing with this one now".

Validation

  - scripts/test-session-tracker.ps1 grew from 59 to 61 checks: the
    title history has to reach sessions.json, and it has to contain the
    first user message, not just the current title.
  - The suite on this machine: 61 pass, 0 fail for the tracker, and
    133 pass, 0 fail, 1 skipped (platform) for the behavioural suite.
  - Negative injection, 7 mutations, each one expected to turn a
    specific check red -- including "the title history is not recorded"
    -> "sessions.json keeps the title history" goes red.
  - Live, on this machine, clicking a row and counting the tabs in
    Windows Terminal before and after:
      * a session whose name matches a live tab: "session terminal
        switched", the target tab became the selected one, and the tab
        count did not change;
      * a session whose name does not match: "session terminal
        activated (tab not matched)", the Terminal came forward, and
        the tab count did not change either.
    Before this change the same two clicks left 7 and 8 tabs behind
    where there had been 6.

Test evidence

  - scripts/test-session-tracker.ps1     -> 61 pass, 0 fail
  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - The name matching is a heuristic and is treated as one: it decides
    which tab, never whether a second terminal appears.
  - UI Automation is Windows-only and the plugin is Windows-only. If the
    assemblies or the window are not there, the code falls through
    instead of failing.
  - No credentials, no network, no telemetry, no third-party service.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
The list showed "I will close the 5h usage chain next. First look at the
current state file and the two write paths." where it used to show
"review the new replies on MiniMax-Code-Plugins PR 62". The title was
not gone -- it had fallen back to the first user message, because the
real title could not be read any more.

The cause is a PowerShell variable-name trap. Variable names are
case-insensitive, so "$home" is the read-only automatic variable
$HOME, the user's home directory. Assigning to it throws
"VariableNotWritable". The assignment sat inside the try/catch that
guards the title read, so the exception was swallowed and the read
returned an empty map -- 11 ms, no node process started at all. The
tracker then fell back to the first user message, which is a sentence
rather than a name.

Fixed by not using that name. The read now returns 413 titles in
196 ms, and the session's title is the name again.

It was my own bug, introduced while making the read work on a host
without USERPROFILE: the fallback path introduced a local $home. It
never showed up in the tests, because the tests point the read at a
temporary database through MCODE_RUNTIME_STATE_DB and never reach the
fallback branch, and because a swallowed exception is invisible from
the outside -- the feature simply did not work.

Two checks now guard it:

  - the source of Runtime-Titles.ps1 may not contain "$home" any more;
  - Get-UserHome has to return something.

Neither of them would have caught this on their own. What caught it
was the list on screen showing the wrong thing, which is why the title
is also read back from the live database after the fix.

Validation

  - scripts/test-session-tracker.ps1 grew from 61 to 63 checks.
  - The suite on this machine: 63 pass, 0 fail for the tracker, and
    133 pass, 0 fail, 1 skipped (platform) for the behavioural suite.
  - Live: the list now reads "review the new replies on
    MiniMax-Code-Plugins PR 62", and the title history holds both that
    name and the earlier sentence.
  - A scan of every .ps1 in the plugin for assignments to read-only
    automatic variables ($HOME, $PID, $PROFILE, $HOST, $MATCHES, ...)
    found no other one.

Test evidence

  - scripts/test-session-tracker.ps1     -> 63 pass, 0 fail
  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - The fallback behaviour is unchanged: when the runtime title cannot
    be read, the first user message is used. That fallback is what made
    the bug visible instead of silent.
  - No new dependency, no network, no credentials.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
…said

The second line of a row showed "<background-task-finished> Local
backend...". The system pushes background-task notifications, task
reminders and agent context into the session log as user messages.
They are not something anyone said, and they were being used as the
summary, and as the title when the runtime title was unavailable.

The title and the summary now skip anything that starts with one of
those injected tags and keep looking. A session whose last message is
only an injected notification simply has no summary, which is honest.

Validation

  - scripts/test-session-tracker.ps1 grew from 63 to 65 checks: an
    injected message must not become the preview, and the title must
    not come from one either.
  - The suite on this machine: 65 pass, 0 fail for the tracker, and
    133 pass, 0 fail, 1 skipped (platform) for the behavioural suite.
  - Negative injection, 2 mutations:
      N21 the preview stops skipping injected messages
          -> "an injected message is not the preview" goes red
      N22 the title stops skipping injected messages
          -> "the title does not come from an injected message" goes red
  - Live: the list shows the session's name on the first line, and the
    second line is empty rather than a wall of XML.

Test evidence

  - scripts/test-session-tracker.ps1     -> 65 pass, 0 fail
  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - Only the injected tags are filtered, and only at the start of the
    text. A person writing "<hello>" still gets a summary.
  - No new dependency, no network, no credentials.
  - UTF-8 with a BOM, LF line endings: both scripts are dot-sourced or
    run by Windows PowerShell 5.1 on a zh-CN host.
The bottom row of the list was clipped -- half a row of it, with empty
space below it inside the window, which made it look even more like a
drawing mistake than a layout one.

The height was computed rather than measured. The list was given
"rows x 48px", and the window was given that plus a constant. Two things
were left out:

  - SessionPanel carries a 14px top margin, 12px of padding and a 1px
    border of its own -- 28px that never showed up in the arithmetic;
  - a row is 46px, not the 48 that was assumed.

With two rows the content needed 106px, the row was given 94px, and the
last row lost 12px. The window still had 14px of slack at the bottom,
because that 14px was the margin, which lives outside the content.

The height is now measured instead of guessed: row 1 is set to Auto, the
layout is forced, and the window is sized from the panel's DesiredSize
plus the margin. That is the height the content actually asks for, and
it stays right when a row is taller than usual.

Two comments in this file had been carrying a replacement character --
text that had been mangled when it was written. One of them ("the
verified ... of it") was left over from an earlier change and was never
looked at; the other came with this one. Both are fixed here, and the
whole plugin was scanned: no replacement characters are left.

Validation

  - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform).
  - Live, on this machine: with two sessions in the list the window is
    now 205px tall instead of 192px, the last row's bottom sits at
    191px and the window's bottom at 219px -- the row is whole, with
    28px of slack instead of a clipped half row.
  - The layout is measured on screen, so there is no unit test for it
    here; the numbers above are the measurement.

Test evidence

  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - The pill itself does not move: row 0 stays locked to the pill
    height, and the list grows below it, exactly as before.
  - The 300ms collapse grace period is untouched.
  - UTF-8 with a BOM, LF line endings: the file is dot-sourced or run by
    Windows PowerShell 5.1 on a zh-CN host.
Clicking a session switched the tab but nothing appeared: the window
stayed behind whatever the user was looking at.

The cause is the foreground lock. SetForegroundWindow is refused for a
process that is not already in the foreground, and the widget is one.
The tab had switched -- the title bar said so -- but the window never
came forward, so from the user's side the click did nothing.

The widget already had this solved for the pill's show/hide toggle: set
the window TOPMOST, then NOTOPMOST again, which lets BringWindowToTop
push it all the way up, and only then SetForegroundWindow. The new code
calls that same routine instead of calling SetForegroundWindow on its
own.

The log now says which process is in the foreground afterwards, so a
switch that did not take is visible instead of silent.

Validation

  - Live, on this machine: the foreground window before the click was
    the desktop app (pid 2496); after the click it was
    WindowsTerminal.exe (pid 59084). The window came forward.
  - The behavioural suite: 133 pass, 0 fail, 1 skipped (platform).

Test evidence

  - node scripts/test-substep-progress.mjs -> 133 pass, 0 fail, 1 skipped

Design compliance

  - The pill's own focus path is untouched; this reuses it.
  - No new dependency, no network, no credentials.
  - UTF-8 with a BOM, LF line endings: the file is dot-sourced or run by
    Windows PowerShell 5.1 on a zh-CN host.
Clicking a row in the session list switched the terminal tab but never
brought the terminal window to the front. From the user's side nothing
happened.

Root cause: `$pid = [uint32]0` in Focus-TerminalWindow and in
Get-ForegroundPid. PowerShell variable names are case-insensitive, so
`$pid` IS the read-only automatic `$PID`; the assignment throws
"Cannot overwrite variable PID because it is read-only or constant".
Both call sites sit inside a `try { } catch { }`, so the exception was
swallowed: Focus-TerminalWindow returned $false before it ever called
Focus-Window, and Get-ForegroundPid always returned 0. The caller
ignored the return value and logged "session terminal switched:
... foregroundPid=0" anyway, so the log looked like a success.

The same class of bug already bit `$home` in Runtime-Titles and `$args`
in the detector. It is worth a guard, not just a rename.

Fix:
  * rename the variable to $procId in both functions
  * read the foreground window back after every focus attempt
    (Test-ForegroundPid) and log FOCUS CONFIRMED / FOCUS NOT
    CONFIRMED. SetForegroundWindow fails silently, so a log line can
    never be the evidence that the window came forward
  * Focus-TerminalWindow falls back to the z-order recipe the pill's
    own toggle already uses (SetWindowPos HWND_TOP without
    SWP_NOACTIVATE, which activates the window)
  * a failed focus must never fall back to opening a second terminal:
    Select-SessionTerminal and Activate-TerminalWindow now report the
    terminal window as found even when the foreground could not be
    confirmed, so one session never ends up with two terminals again
  * Focus-Window's log line says DONE instead of OK -- it only means
    the calls did not throw

Test evidence:
  * node scripts/smoke.mjs -> 153 pass / 0 fail
  * node scripts/test-substep-progress.mjs -> 133 pass / 0 fail / 1 skipped
  * pwsh -File scripts/test-session-tracker.ps1 -> 65 pass / 0 fail
  * pwsh -File scripts/test-windows-workflow-local.ps1 -> all 5 steps OK
  * new guard in smoke.mjs fails the build on any assignment to a
    read-only PowerShell automatic variable ($pid, $home, $host, ...),
    including inside a param() block. Negative injections N23-N27 make
    it fail, N28-N31 (comment / string / here-string / the `$null =`
    discard) must not fire, and the tree restores byte-identical.
  * real-machine end-to-end with a real mouse_event LEFTDOWN/LEFTUP on
    the session row: with this build the foreground window becomes
    WindowsTerminal.exe and the selected tab is the clicked session.
    With the previous build (the parent commit) the foreground stays on
    the app (pid 2496) and the same check fails. The log now shows
    "FOCUS CONFIRMED: pid=59084" followed by
    "session terminal switched: ... foregroundPid=59084", where it used
    to say foregroundPid=0.

Design compliance:
  * one commit, one thing
  * reuses the existing Focus-Window / Toggle-Window recipe instead of
    introducing a third way to steal the foreground
  * a session click never opens a new terminal window
Clicking a session row brought the terminal forward, but as a ~480x84
strip in the top-left corner of the screen instead of a full-screen
window. The user reported it with a screenshot: "点完它是在左上角小小
的一条,而不是全屏".

Root cause: Focus-Window's two SetWindowPos calls pass cx=0/cy=0 and
X=0/Y=0 as a shorthand for "keep the current size / position", but
that shorthand only holds when the matching flag is present. The flags
were SWP_NOACTIVATE alone:

    SetWindowPos(hwnd, HWND_TOPMOST,   0, 0, 0, 0, SWP_NOACTIVATE)
    SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE)

Windows therefore read them as "resize to 0x0" and "move to (0,0)".
Windows Terminal clamps a 0x0 request to its minimum size, which is
the 480x84 strip, and the window really moved to the top-left corner.
The window's restored ("normal") placement then stayed 480x84@(0,0),
so every later restore brought the strip back.

The bug was latent until the previous commit made Focus-TerminalWindow
actually reach Focus-Window -- before that, the $PID collision threw
first and the whole focus step never ran. This file already documents
the trap in Toggle-Window ("SWP_NOSIZE: cx=0/cy=0 缺它会被 Windows 当成
resize 到 0x0, 触发 WT 的 min-size 兜底, 变成 480x76 strip"), it just
was never applied to Focus-Window.

Fix:
  * Focus-Window's SetWindowPos calls now carry SWP_NOSIZE | SWP_NOMOVE
    (plus SWP_NOACTIVATE), so they only touch the z-order
  * Focus-Window no longer calls SW_SHOW on a window that is already
    visible -- pointless state churn on an already-maximized window
  * "show it full-screen" now lives in one helper, Show-WindowFullScreen,
    used by both the pill's toggle and the session-list click, so the
    two paths cannot drift apart again
  * the helper runs SW_RESTORE -> SetWindowPos(work area) -> SW_MAXIMIZE,
    in that order. The SetWindowPos has to happen while the window is
    in its normal state: on an already-maximized window Windows does not
    update the restored size, so a stale size (the 480x84 strip) would
    survive the next restore. SetWindowPlacement was tried and does not
    work on Windows Terminal -- the rcNormalPosition it is given reads
    back as 0x0.

Test evidence:
  * node scripts/smoke.mjs -> 154 pass / 0 fail
  * node scripts/test-substep-progress.mjs -> 133 pass / 0 fail / 1 skipped
  * pwsh -File scripts/test-session-tracker.ps1 -> 65 pass / 0 fail
  * pwsh -File scripts/test-windows-workflow-local.ps1 -> all 5 steps OK
  * new guard in smoke.mjs fails the build on any SetWindowPos that
    passes cx=0/cy=0 without SWP_NOSIZE, or X=0/Y=0 without SWP_NOMOVE.
    Negative injections N32-N34 fire it; N35/N38 (an explicit size with
    SWP_NOMOVE, work-area coordinates as variables) must not; the tree
    restores byte-identical.
  * the round-15/16 drift locks moved with the implementation: they now
    check Show-WindowFullScreen and both of its callers instead of the
    text inside Toggle-Window. Negative injection N36 (SW_MAXIMIZE
    regressed to SW_SHOW) and N37 (the session click stops
    full-screening) fire them.
  * real-machine end-to-end, real mouse_event LEFTDOWN/LEFTUP on the
    session row: with this build the foreground window is Windows
    Terminal, its rect is 2576x1408 (covers the 2560x1392 work area)
    and its restored placement is 2560x1392 -- a real full-screen size.
    With the previous build the foreground stays on the app and the
    window is a strip; the same check fails.

Design compliance:
  * one commit, one thing
  * reuses the existing Toggle-Window full-screen recipe instead of
    introducing a fourth way to raise a window
  * a session click never opens a new terminal window
@antianqi
antianqi merged commit 0c22cc7 into feat/substep-progress Oct 24, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant