Skip to content

Commit e69d750

Browse files
authored
Copy a session's id, and its transcript, mid-turn (SPEC-52) (#164)
* SPEC-51: session identity — spec, plan and design board Copy the underlying agent session id (pi's ACP sessionId / codex threadId) and its on-disk transcript path, mid-turn. Typing pi's /session gets queued behind the running turn; a client command and a menu never do. Spec is rev 2: two independent reviews ran against rev 1 before any code. They caught three things worth having: - D3 was unbuildable. Session has no project path, and pi's transcript slug follows the WORKTREE cwd, not the project root. The nearest precedent (attachPiSession) uses the project path, so copying it would have missed the transcript for every worktree-bound session -- and D9 would have hidden the failure. Resolution moves to SessionManager, keyed on worktreePath ?? project.dto.path, memoized (a readdir per snapshot was unaffordable). - D15: the mockup's 'pi --session 019ff121' was unsafe. pi ids are UUIDv7, whose first 48 bits are a ms timestamp, so 8 chars leave ~65s of ambiguity; real collisions exist on this machine. Review went further and drove it: pi does not error on an ambiguous prefix, it silently picks one and offers to fork it. Full id everywhere. - D4's '~/' abbreviation was unimplementable: the path belongs to the server host and the app cannot know that host's home dir. Cut on review: the identity section inside the context-usage panel (the ring is absent in exactly the four states where the id is wanted), and one of the three panel doors (a tab-menu item duplicating the pane kebab). Added on review: a11y as a locked decision (D18), watch-not-snapshot so a panel opened before the id is assigned fills in live (D19), and the server-path disclosure written down rather than assumed (D21). * SPEC-51 P1a: the session-identity panel, UI first (no wire yet) Phase A of the plan: build and pixel-verify the whole surface BEFORE freezing the wire contract. The widgets take an app-level SessionIdentity, never a SessionDTO, which is what makes that order possible -- and is the right dependency direction anyway. SessionIdentity per-agent vocabulary as a lookup TABLE, not a switch (ENGINEERING.md's OCP rule), with a safe default that gives an unknown agent a generic label and no resume line rather than an invented CLI. sessionIdentityText() pure; THE copy contract, one place, four callers. SessionIdentityDetails one Copy all (D5), rows omitted when unmeasured (D9), read-only (D8), semantics labels (D18). showSessionIdentity() sheet on mobile, window-clamped popover on desktop. 18 tests, each with a mutation proven to fail it. Two of those mutations initially did NOT bite, and both were my bugs rather than the tests': - the clipboard-failure test asserted on rendered toast text, but the test host has no toast overlay, so findsNothing could never fail. It now asserts against the StatusCenter directly. - two mutation scripts silently no-oped on a Python escaping bug, which looked exactly like a vacuous test until checked. Found by the pixel gate on the real macOS app, not by any test: "1 lines". Reachable in production on a stub/detached session whose only measured value is the makit id. Fixed, and the 1-line case is now asserted in both the visible label and the semantics label. Also fixes a real bug in test/status/status_lifetime_test.dart (SPEC-48 D3's repo-wide guard): it sliced the body out of the ORIGINAL source, so a comment naming the rule it follows supplied the "first await" and turned the correctly-hoisted line below it into an offender -- and a commented-out ref.status counted as a real one. It now scans the blanked copy; offsets are preserved so line numbers still map. Red test added for the false positive, and the guard is proven still to bite by un-hoisting. QA evidence (cua-driver, real macOS window, both themes, 320 and 375pt): both uuid rows render on ONE line -- the measured payoff for dropping the per-row copy column; light-mode Copy all measures 10.95:1 (needs 4.5); line counts 4/3/3/2/1 all correct; draft renders 'Agent not started yet'. Note for anyone repeating that gate: Flutter's macOS AX tree exposes nothing but the menu bar, and synthesized clicks never reached the app (the staleness stamp proved it), so the harness renders every state in one pass and needs no input at all. scroll DID work. Mockup corrected to the shipped type scale (sentence case, as ContextUsageDetails ships) and its in-panel footnotes dropped. * SPEC-51 P1b: freeze the wire contract — agentSessionId + transcriptPath Two OPTIONAL fields on SessionDTO, and their app-side decode. Committed on its own, before either implementation, so the parallel server/app work cannot both edit the shared shape. Frozen spelling: `agentSessionId?: string`, `transcriptPath?: string`. Optional is the decision, not an accident: a newer app paired with an older server must render one fewer row rather than a fabricated one, which is the rule createdAt already follows (SPEC-47 D12). App side normalises '' to null at the decode edge. That is not paranoia -- '' is what a sloppy or partially-migrated server sends for "no value", and an empty string here would render a copy affordance that copies nothing, i.e. exactly the placeholder D9 forbids. Non-strings are rejected the same way, so a malformed snapshot degrades one field instead of failing the whole session list. Nothing populates the fields yet -- that is C1b, and its tests are where the bite lives. The vacuous "a DTO built without them is undefined" test from plan rev 1 is deliberately NOT here: optional TS fields are absent by default with zero production code, and JSON.stringify drops undefined. server: tsc clean, 1313 pass / 0 fail (baseline preserved) app: analyze clean, 5 new codec tests green * SPEC-51 P1c: populate the fields (server) and wire the doors (app) Two agents on disjoint trees, then verified here rather than taken on report. server (C1): transcript-path.ts NEW. Agent-agnostic dispatcher: resumeSessionPath verbatim -> pi-only directory scan -> undefined. In its own module because two of its three branches are not pi-specific, so P2's codex resolver is additive rather than a move out of a pi-named file. Never throws. manager.ts projectSessionDTO() populates both fields at the two wire sites (sessions.snapshot, session.listClosed). Session.toDTO() CANNOT do this -- a Session holds projectId + worktreePath, never the project's FS path. cwd = worktreePath ?? project path, because pi's slug follows the cwd pi actually ran in and that is usually the worktree; the nearest precedent (attachPiSession) uses the project path and would have missed every worktree-bound session, with D9 hiding the failure. Memoized per session id including misses: a snapshot is rebroadcast on every metaChanged, so resolving per projection meant a readdir per session per broadcast. app (C2): sessionIdentityProvider store -> SessionIdentity; null FIELDS, never null, never throws, so no call site has to branch. /session a CLIENT command next to /name, so it is intercepted before sendMessage and works MID-TURN. That is the whole bug: pi's own /session is an agent command and gets queued behind the turn. `/session id` copies the bare id; bare opens panel. two doors + tab copy glass menu, pane kebab, and Copy session id on the tab menu (not a third panel door -- cut on review). showSessionIdentity is now exactly-one-of {sessionId, identity}, asserted. The first wiring took a required identity AND an optional sessionId, so all three doors did a ref.read whose result was then discarded: an argument that is ignored depending on another argument is a trap, not an API. Mutations proven to bite (9 across both trees), including the two that matter most: cwd -> project path fails the worktree test, and a prefix-relaxed suffix match fails the D15 collision test. server: tsc clean, 1326 pass / 0 fail (baseline 1313) app: analyze clean, 0 non-loading failures, 55 SPEC-51 tests green * docs(SPEC-51): record the verification evidence and flip the status Phase D step 5. The Verification section said "filled in on implementation" and the status still said Draft; both are now true statements about the shipped code. The one gate that had no recorded result was the live probe, so it was run: resolveTranscriptPath against the real ~/.pi/agent/sessions tree, for this branch's own worktree, using the pi session id of the session that implemented the feature. It returned the transcript path byte-identical to the one pi itself reports. The same call with the 8-char prefix returned undefined -- D15 holds on real data, not just in the unit test's fixture dir. `pi --help` confirms the resume line's spelling is `--session <path|id>`. Also noted the tenth mutation: relaxing handleClientCommand's exact name match to startsWith, which `/sessions` is NOT intercepted catches. That mutation was left in the working tree by the previous session and has been reverted -- exact matching is the shipped behaviour. Status is Implemented for P1 only; the codex resolver branch and D12's section inside the usage panel stay deferred, so the header says so rather than implying the whole spec landed. * style(app): apply dart format to the SPEC-51 files Whitespace and trailing commas only — verified by comparing both revisions with all whitespace and commas stripped, so no token changed. `dart format --set-exit-if-changed` is now clean across lib/test/tool, analyze reports no issues, and the 52 SPEC-51 tests in these files stay green. The pre-push hook writes these fixes itself and then fails the push, so they have to land as a commit rather than as a dirty tree. * fix(app): report a failed id copy, and survive the tab closing under the menu Three review findings, each with a red test written first. 1. HIGH -- `/session id` awaited Clipboard.setData with no try/catch, so a PlatformException (another process holds the clipboard on Windows; the host denies the write) escaped the handler: the user got neither the id nor a word about why. The panel's `Copy all` already had this guard; the two bare-id paths did not. The review flagged client_commands.dart. The IDENTICAL bug sat in split_view.dart's tab-menu copy, unflagged, so both are fixed -- three copy paths in one feature must not disagree about what a failed write does. All three now report `failure`, not `warning`: an action the user asked for did not happen, which is what status.failure is for. The panel's `warning` was the odd one out and moved too. Severity is now ASSERTED in all three tests, so the consistency is enforced rather than incidental -- flipping any one back to warning fails its test (proven). 2. HIGH -- the tab menu's ref.read(sessionIdentityProvider) ran AFTER the showMenu await. That menu lives in the Navigator's overlay, so it outlives the _TabChip that opened it: close the tab while the menu is open (a server snapshot dropping the session does it for real) and the read hits a dead ref -- `Bad state: Using "ref" when a widget is about to or has been unmounted is unsafe.` A crash instead of a copy. The new test reproduces it exactly that way, by calling unbindSession while the menu is up, and it failed with that message before the fix. Hoisted next to `status`, which is already hoisted for this very reason (SPEC-48 D3). Chose that over the reviewer's alternative of a context.mounted guard: the guard keeps the read fresh but still leaves `ref` use after an await, which is the hazard D3 exists to remove. The cost is that the id is sampled at menu-open rather than at click -- sub-second for right-click -> click, and the surface that must fill in live is the panel, which watches (D19). Measured rather than assumed: broadening status_lifetime_test.dart to catch ref.read/ref.watch after an await repo-wide -- which would have caught this automatically -- reports 24 bare sites across lib/. Out of scope here, so it is named as a follow-up instead of pretending this PR fixed a repo-wide pattern. 3. LOW -- three stale statements in mockups/session-identity.html, each of which misleads someone reading it as the design source: - "Three things on this page were wrong" above a list of six; - prose still claiming the panel DISPLAYS `~/` paths, contradicting both the rendered frames and the amendment 20 lines above it (D4 dropped `~/`); - the delta table still promising `Session details...` in the tab menu -- the fourth door D13 explicitly cut. That is the dangerous one: a delta table reads as a work list. Markup re-validated (balanced tags) after the edits. * docs: renumber this spec 51 -> 52, because 51 and 50 both shipped first Not a cosmetic rename. While this branch was in flight, `main` took BOTH numbers next to it: SPEC-51 Preview groups (#163) -- docs/UX.md + 14 shipped source files SPEC-50 Profiles (#162) -- a whole server-profiles feature So `SPEC-51` in this branch's code comments pointed at a different, already-shipped feature, and `docs/specs/` held two SPEC-51 files. Neither shipped spec can move, so this one takes 52, the next free number. Renumbered ONLY this feature's own files, derived from this branch's commits (1b92874..2a95bf7 plus the fix commit) rather than by a blanket search-replace: a global `SPEC-51 -> SPEC-52` would have rewritten preview groups' own references in 14 files and docs/UX.md, silently pointing them at this spec instead. Each of the remaining 14 SPEC-51 references was checked to be preview groups'. The branch name (feat/get-session-id) is left alone deliberately -- renaming a pushed branch orphans its PR for no gain. Recorded in the spec header, including the part a rename does not fix: nothing in this repo allocates spec numbers, so two branches drafted in one week collide in silence. `main` already carries that proof -- SPEC-48 names both 2026-08-09-SPEC-48-status-and-activity.md and 2026-08-10-SPEC-48-per-repo-settings.md. Verified after the renumber, on the merge with the NEWER main: server: tsc clean, 2072 pass / 0 fail app: analyze clean, dart format clean, 0 non-loading failures * fix(app): the desktop panel is centred on purpose — say so, and test it Fourth review thread. The doc comment promised "an anchored popover on desktop. Same split as ContextUsageButton (SPEC-37)" and D11 specified a MenuAnchor; the code ships showDialog + Alignment.center. So this was not a stale comment, it was an unrecorded spec deviation. Kept the centring, and the reason is a real finding rather than a shrug: the mechanism cannot transfer, because the DOOR TOPOLOGY differs. ContextUsageButton is a persistent control in the composer, so a MenuAnchor has something to stay anchored to for as long as the popover is open. Both of this panel's desktop doors are transient MENU ITEMS -- the pane-header kebab and the mobile glass menu -- so by the time an item is chosen its menu has been dismissed. Anchoring to where a vanished menu item used to be is arbitrary placement dressed up as precision. What SPEC-37 actually contributes is kept: the window-clamped width and the SingleChildScrollView, so a panel opened from a narrow split pane cannot hang off-screen. The real defect underneath: the desktop host had NO test. Every case in session_identity_widget_test.dart passed `desktop: false`, which is exactly how the code and its own doc comment drifted apart and stayed that way through implementation. Two tests added: * desktop opens a centred, window-clamped panel -- not a sheet * a desktop panel in a narrow window is clamped to the window (300pt window, 340pt panel, so the clamp is load-bearing) Both mutation-proven: Alignment.center -> topLeft fails the first, and dropping the math.min window clamp fails the second. D11 amended in the spec and logged as deviation 8, so the next reader does not find a third version of the truth. * fix(app): floor the panel width at zero, in BOTH panels that compute it Fifth review thread, and it was right: window - 2 * margin goes NEGATIVE once the window is narrower than the margins, and a BoxConstraints/SizedBox with a negative max is not normalized -- the layout ASSERTS ("BoxConstraints has both width and height constraints non-normalized") instead of rendering a cramped panel. "The window is absurd" should cost an ugly panel, not a crash. Confirmed by repro before fixing, and the repro is the interesting part. My first attempt -- open the panel in a 20x20 window -- PASSED while proving nothing: at that size the trigger button is unhittable, so the tap lands on nothing and the panel never opens. A test that cannot fail. Both tests now shrink the window WHILE the panel is open, which is also the realistic path: a resize animation or an embedded host hands us one degenerate frame. session_identity.dart maxWidth AND maxHeight floored at 0 (threshold 24pt, margin 12). context_usage.dart the SPEC-37 panel this one copied its sizing from has the SAME bug on the width axis, at a 16pt threshold (margin 8). Its height axis was already safe, floored by _kUsagePanelMinHeight; the width axis had no floor. Not in this PR's diff, but confirmed real with a red test, so it is fixed here per AGENTS.md rather than left for someone to hit. I nearly recorded that second one as "not reproducible": my first attempt used a 20pt window, which is negative for a 12pt margin but still POSITIVE for an 8pt one, so the assert did not fire. It needed 10pt. Worth stating because the near-miss is the whole lesson -- a threshold bug needs a size chosen from the constant, not reused from the previous test. Same coverage hole under both: neither panel had a single `desktop: true` test, so neither desktop host was covered at all. That is also how the identity panel's doc comment came to describe an anchored popover while shipping a centred dialog.
1 parent c05e275 commit e69d750

25 files changed

Lines changed: 3970 additions & 9 deletions

app/lib/desktop/chat/panes/pane_header.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import '../../../store/store.dart';
99
import '../../../status/status_event.dart';
1010
import '../../../status/status_providers.dart';
1111
import '../../../ui/composer/client_commands.dart';
12+
import '../../../ui/session/session_identity.dart';
1213
import '../../../ui/widgets/menu_item.dart';
1314
import '../sidebar_layout.dart';
1415
import '../title_bar_strip.dart';
@@ -145,6 +146,15 @@ class SessionActionsMenu extends ConsumerWidget {
145146
ref: ref,
146147
sessionId: sessionId,
147148
);
149+
case 'details':
150+
// Reads state the client already holds (D13) — not capability
151+
// gated. `desktop: true` for the anchored popover, `sessionId` so
152+
// the open panel watches and fills in live (D19).
153+
showSessionIdentity(
154+
context: context,
155+
desktop: true,
156+
sessionId: sessionId,
157+
);
148158
case 'quit':
149159
_confirmClose(context, ref);
150160
}
@@ -155,6 +165,11 @@ class SessionActionsMenu extends ConsumerWidget {
155165
icon: PhosphorIconsLight.pencilSimple,
156166
label: 'Rename session',
157167
),
168+
themedMenuItem(
169+
value: 'details',
170+
icon: PhosphorIconsLight.fingerprint,
171+
label: 'Session details',
172+
),
158173
const PopupMenuDivider(),
159174
themedMenuItem(
160175
value: 'quit',

app/lib/desktop/chat/split_view.dart

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart' hide Tab, Split;
2+
import 'package:flutter/services.dart';
23
import 'package:flutter_riverpod/flutter_riverpod.dart';
34
import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
45

@@ -7,6 +8,7 @@ import '../../status/status_event.dart';
78
import '../../status/status_providers.dart';
89
import '../../store/store.dart';
910
import '../../ui/composer/client_commands.dart';
11+
import '../../ui/session/session_identity.dart';
1012
import '../../ui/widgets/menu_item.dart';
1113
import 'desktop_chat_pane.dart';
1214
import 'groups/agent_picker.dart';
@@ -665,9 +667,11 @@ class _TabChip extends ConsumerWidget {
665667
);
666668
}
667669

668-
/// Tab context menu (right-click / long-press). One item — "Rename session"
669-
/// — styled to the design system's primary body scale (`bodyMedium` text
670-
/// with a matching 16px glyph).
670+
/// Tab context menu (right-click / long-press): **Rename session** and
671+
/// **Copy session id**. Deliberately NOT a *Session details…* item (D13): a
672+
/// third door onto the same sheet, one pixel from the pane-header kebab on the
673+
/// same platform, was cut on review. **Copy session id** stays because it is a
674+
/// different job — right-click → one click → the bare id, no dialog.
671675
Future<void> _showContextMenu(
672676
BuildContext context,
673677
WidgetRef ref,
@@ -678,6 +682,23 @@ class _TabChip extends ConsumerWidget {
678682
if (overlayState == null) return;
679683
final overlayBox = overlayState.context.findRenderObject();
680684
if (overlayBox is! RenderBox) return;
685+
// Resolved before the `showMenu` await (SPEC-48 D3): `ref` dies with its
686+
// widget, and the copy path reports its outcome after an await.
687+
final status = ref.status;
688+
// The identity is hoisted for the SAME reason, and it is not optional care:
689+
// this menu lives in the Navigator's overlay, so it outlives the tab chip
690+
// that opened it. Close the tab while the menu is open — a server snapshot
691+
// dropping the session does it for real — and a `ref.read` down in the
692+
// `copyId` branch would run on a dead `ref` and throw `Cannot use "ref"
693+
// after the widget was disposed`, i.e. crash instead of copying.
694+
//
695+
// The cost is that the id is sampled at menu-open rather than at click. That
696+
// is sub-second for a right-click → click, and it is the RIGHT trade here:
697+
// the live-filling surface is the panel, which watches (D19). Rejected
698+
// alternative: guarding the late read with `context.mounted`, which keeps the
699+
// read fresh but leaves `ref` use after an await — the hazard SPEC-48 D3
700+
// exists to remove.
701+
final identity = ref.read(sessionIdentityProvider(sessionId));
681702
final selected = await showMenu<String>(
682703
context: context,
683704
position: RelativeRect.fromRect(
@@ -691,8 +712,47 @@ class _TabChip extends ConsumerWidget {
691712
icon: PhosphorIconsLight.pencilSimple,
692713
label: 'Rename session',
693714
),
715+
themedMenuItem(
716+
value: 'copyId',
717+
icon: PhosphorIconsLight.copy,
718+
label: 'Copy session id',
719+
),
694720
],
695721
);
722+
if (selected == 'copyId') {
723+
// The BARE agent session id (D6), not `sessionIdentityText` — that whole
724+
// label:value payload is `Copy all`'s job in the panel. No dialog.
725+
final id = identity.agentSessionId;
726+
if (id == null) {
727+
status.warning(
728+
'No agent session id yet',
729+
source: StatusSources.session,
730+
sessionId: sessionId,
731+
);
732+
return;
733+
}
734+
// A clipboard write can throw for real (another process holds it on
735+
// Windows; the host denies it). Unreported, the user gets neither the id
736+
// nor a reason. Same contract as the panel's `Copy all` and `/session id`.
737+
try {
738+
await Clipboard.setData(ClipboardData(text: id));
739+
} catch (e) {
740+
status.failure(
741+
'Could not copy session id',
742+
error: e,
743+
source: StatusSources.session,
744+
sessionId: sessionId,
745+
);
746+
return;
747+
}
748+
status.info(
749+
'Session id copied',
750+
source: StatusSources.session,
751+
detail: id,
752+
sessionId: sessionId,
753+
);
754+
return;
755+
}
696756
if (selected != 'rename' || !context.mounted) return;
697757
await handleClientCommand(
698758
'/name',

app/lib/store/models.dart

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1237,6 +1237,8 @@ class Session {
12371237
this.parentId,
12381238
this.handoffReason,
12391239
this.origin,
1240+
this.agentSessionId,
1241+
this.transcriptPath,
12401242
this.queued = const [],
12411243
});
12421244

@@ -1296,6 +1298,18 @@ class Session {
12961298
/// Null on pre-SPEC-46 rows; a plain string so an unknown value never throws.
12971299
final String? origin;
12981300

1301+
/// The underlying agent's own session id — pi's ACP `sessionId` (which is pi's
1302+
/// OWN session uuid, reused by `pi-acp`) or codex's `threadId`. Null for a
1303+
/// draft, for a back end with no native session concept, and for any server
1304+
/// older than SPEC-52 (D1).
1305+
final String? agentSessionId;
1306+
1307+
/// Absolute path to the transcript on the SERVER's host, resolved server-side
1308+
/// (D2/D3) — the app never derives it, because the slug algorithm is pi's and
1309+
/// the app cannot stat the server's filesystem to check itself. Null for codex
1310+
/// in P1 (D16) and whenever no file was found (D9).
1311+
final String? transcriptPath;
1312+
12991313
/// Messages submitted while the agent was busy that could not be steered into
13001314
/// the running turn (SPEC-35), oldest first. They are delivered one per idle
13011315
/// transition and can be cancelled until then.
@@ -1319,6 +1333,8 @@ class Session {
13191333
String? parentId,
13201334
String? handoffReason,
13211335
String? origin,
1336+
String? agentSessionId,
1337+
String? transcriptPath,
13221338
List<QueuedMessage>? queued,
13231339
}) => Session(
13241340
id: id,
@@ -1341,6 +1357,8 @@ class Session {
13411357
parentId: parentId ?? this.parentId,
13421358
handoffReason: handoffReason ?? this.handoffReason,
13431359
origin: origin ?? this.origin,
1360+
agentSessionId: agentSessionId ?? this.agentSessionId,
1361+
transcriptPath: transcriptPath ?? this.transcriptPath,
13441362
queued: queued ?? this.queued,
13451363
);
13461364
}

app/lib/transport/codec.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,13 +274,24 @@ class WireCodec {
274274
? j['handoffReason'] as String
275275
: null,
276276
origin: j['origin'] is String ? j['origin'] as String : null,
277+
// SPEC-52 D1/D9: normalise `''` to null at the edge. A blank string is
278+
// what a sloppy server sends for "no value", and it would render a copy
279+
// affordance that copies nothing — the placeholder D9 forbids. Doing it
280+
// here means nothing above this line has to think about it.
281+
agentSessionId: _nonEmpty(j['agentSessionId']),
282+
transcriptPath: _nonEmpty(j['transcriptPath']),
277283
queued: decodeQueued(j['queued']),
278284
),
279285
);
280286
}
281287
return out;
282288
}
283289

290+
/// A non-empty string, or null. Rejects non-strings too, so a malformed
291+
/// snapshot degrades one field instead of failing the whole session list.
292+
static String? _nonEmpty(Object? v) =>
293+
(v is String && v.isNotEmpty) ? v : null;
294+
284295
/// Decode a session's `queued` array (SPEC-35). Absent/malformed entries yield
285296
/// an empty queue rather than failing the whole snapshot: a session list is
286297
/// too important to drop over a pending-message chip.

app/lib/ui/composer/client_commands.dart

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
library;
88

99
import 'package:flutter/material.dart';
10+
import 'package:flutter/services.dart';
1011
import 'package:flutter_riverpod/flutter_riverpod.dart';
1112
import 'package:go_router/go_router.dart';
1213
import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
@@ -20,6 +21,7 @@ import '../../status/status_event.dart';
2021
import '../../status/status_providers.dart';
2122
import '../widgets/sheet_header.dart';
2223
import '../widgets/searchable_list_sheet.dart';
24+
import '../session/session_identity.dart';
2325
import '../../app/routes.dart';
2426

2527
typedef ClientCmdHandler =
@@ -264,6 +266,71 @@ final List<ClientCommand> clientCommands = <ClientCommand>[
264266
);
265267
},
266268
),
269+
ClientCommand(
270+
name: 'session',
271+
description: 'Show this session’s identity, or /session id to copy its id',
272+
handler: (context, ref, {required sessionId, required arg}) async {
273+
// WHY a CLIENT command and not sent to the agent (D7): pi's own `/session`
274+
// is an agent command, so in makit's composer it would fall through to
275+
// `store.sendMessage` and — mid-turn — land in the server's pending queue,
276+
// executing only after the turn it was meant to help you hand off.
277+
// Intercepting it here answers at 100% of a turn. This handler returning
278+
// (via `handleClientCommand` matching) is the fix for that bug.
279+
//
280+
// Resolved before any await (SPEC-48 D3, enforced by
281+
// `test/status/status_lifetime_test.dart`): `ref` dies with its widget.
282+
final status = ref.status;
283+
// `/session id` copies ONLY the bare agent session id (D6). The panel's
284+
// `Copy all` is the "give me everything" job; this is "give me the id", so
285+
// it must not emit the whole label:value payload.
286+
if (arg == 'id') {
287+
final id = ref.read(sessionIdentityProvider(sessionId)).agentSessionId;
288+
if (id == null) {
289+
// Say why rather than copying an empty string: a draft (or a back end
290+
// with no native session concept) has no id to hand off yet.
291+
status.warning(
292+
'No agent session id yet',
293+
source: StatusSources.session,
294+
sessionId: sessionId,
295+
);
296+
return;
297+
}
298+
// A clipboard write can throw for real (another process holds it on
299+
// Windows; the host denies it). Unreported, the user gets neither the id
300+
// nor a reason — so the write is waited on, and only a write that landed
301+
// is allowed to claim success. Same contract as the panel's `Copy all`.
302+
try {
303+
await Clipboard.setData(ClipboardData(text: id));
304+
} catch (e) {
305+
status.failure(
306+
'Could not copy session id',
307+
error: e,
308+
source: StatusSources.session,
309+
sessionId: sessionId,
310+
);
311+
return;
312+
}
313+
status.info(
314+
'Session id copied',
315+
source: StatusSources.session,
316+
detail: id,
317+
sessionId: sessionId,
318+
);
319+
return;
320+
}
321+
// Bare `/session` opens the panel. Presented as a bottom sheet
322+
// (`desktop: false`) like the other client commands (`/model`,
323+
// `/thinking`): the invocation comes from the composer, where a sheet is
324+
// the established surface. `sessionId` is passed so the open panel watches
325+
// and fills in live (D19).
326+
if (!context.mounted) return;
327+
await showSessionIdentity(
328+
context: context,
329+
desktop: false,
330+
sessionId: sessionId,
331+
);
332+
},
333+
),
267334
ClientCommand(
268335
name: 'name',
269336
description: 'Rename this session (shown in the session list)',

app/lib/ui/composer/context_usage.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,19 @@ class ContextUsageButton extends ConsumerWidget {
272272
),
273273
),
274274
child: SizedBox(
275-
width: math.min(
276-
kUsagePanelWidth,
277-
window.width - 2 * _kUsagePanelMargin,
275+
// Floored at zero: `window.width - 2 * margin` goes negative below
276+
// 16pt, and a negative SizedBox width is a non-normalized
277+
// constraint — the layout asserts instead of rendering a cramped
278+
// panel. Reachable for a frame when the window shrinks under an
279+
// open popover. The height axis above was already safe, floored by
280+
// `_kUsagePanelMinHeight`; this axis had no floor.
281+
// (Found via SPEC-52's identity panel, which copied this sizing.)
282+
width: math.max(
283+
0,
284+
math.min(
285+
kUsagePanelWidth,
286+
window.width - 2 * _kUsagePanelMargin,
287+
),
278288
),
279289
// Scrolls inside the height cap rather than clipping the cost
280290
// line off the bottom. `primary: false` because `MenuAnchor`

0 commit comments

Comments
 (0)