Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/lib/desktop/chat/panes/pane_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import '../../../store/store.dart';
import '../../../status/status_event.dart';
import '../../../status/status_providers.dart';
import '../../../ui/composer/client_commands.dart';
import '../../../ui/session/session_identity.dart';
import '../../../ui/widgets/menu_item.dart';
import '../sidebar_layout.dart';
import '../title_bar_strip.dart';
Expand Down Expand Up @@ -145,6 +146,15 @@ class SessionActionsMenu extends ConsumerWidget {
ref: ref,
sessionId: sessionId,
);
case 'details':
// Reads state the client already holds (D13) — not capability
// gated. `desktop: true` for the anchored popover, `sessionId` so
// the open panel watches and fills in live (D19).
showSessionIdentity(
context: context,
desktop: true,
sessionId: sessionId,
);
case 'quit':
_confirmClose(context, ref);
}
Expand All @@ -155,6 +165,11 @@ class SessionActionsMenu extends ConsumerWidget {
icon: PhosphorIconsLight.pencilSimple,
label: 'Rename session',
),
themedMenuItem(
value: 'details',
icon: PhosphorIconsLight.fingerprint,
label: 'Session details',
),
const PopupMenuDivider(),
themedMenuItem(
value: 'quit',
Expand Down
66 changes: 63 additions & 3 deletions app/lib/desktop/chat/split_view.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart' hide Tab, Split;
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';

Expand All @@ -7,6 +8,7 @@ import '../../status/status_event.dart';
import '../../status/status_providers.dart';
import '../../store/store.dart';
import '../../ui/composer/client_commands.dart';
import '../../ui/session/session_identity.dart';
import '../../ui/widgets/menu_item.dart';
import 'desktop_chat_pane.dart';
import 'groups/agent_picker.dart';
Expand Down Expand Up @@ -665,9 +667,11 @@ class _TabChip extends ConsumerWidget {
);
}

/// Tab context menu (right-click / long-press). One item — "Rename session"
/// — styled to the design system's primary body scale (`bodyMedium` text
/// with a matching 16px glyph).
/// Tab context menu (right-click / long-press): **Rename session** and
/// **Copy session id**. Deliberately NOT a *Session details…* item (D13): a
/// third door onto the same sheet, one pixel from the pane-header kebab on the
/// same platform, was cut on review. **Copy session id** stays because it is a
/// different job — right-click → one click → the bare id, no dialog.
Future<void> _showContextMenu(
BuildContext context,
WidgetRef ref,
Expand All @@ -678,6 +682,23 @@ class _TabChip extends ConsumerWidget {
if (overlayState == null) return;
final overlayBox = overlayState.context.findRenderObject();
if (overlayBox is! RenderBox) return;
// Resolved before the `showMenu` await (SPEC-48 D3): `ref` dies with its
// widget, and the copy path reports its outcome after an await.
final status = ref.status;
// The identity is hoisted for the SAME reason, and it is not optional care:
// this menu lives in the Navigator's overlay, so it outlives the tab chip
// that opened it. Close the tab while the menu is open — a server snapshot
// dropping the session does it for real — and a `ref.read` down in the
// `copyId` branch would run on a dead `ref` and throw `Cannot use "ref"
// after the widget was disposed`, i.e. crash instead of copying.
//
// The cost is that the id is sampled at menu-open rather than at click. That
// is sub-second for a right-click → click, and it is the RIGHT trade here:
// the live-filling surface is the panel, which watches (D19). Rejected
// alternative: guarding the late read with `context.mounted`, which keeps the
// read fresh but leaves `ref` use after an await — the hazard SPEC-48 D3
// exists to remove.
final identity = ref.read(sessionIdentityProvider(sessionId));
final selected = await showMenu<String>(
context: context,
position: RelativeRect.fromRect(
Expand All @@ -691,8 +712,47 @@ class _TabChip extends ConsumerWidget {
icon: PhosphorIconsLight.pencilSimple,
label: 'Rename session',
),
themedMenuItem(
value: 'copyId',
icon: PhosphorIconsLight.copy,
label: 'Copy session id',
),
],
);
if (selected == 'copyId') {
// The BARE agent session id (D6), not `sessionIdentityText` — that whole
// label:value payload is `Copy all`'s job in the panel. No dialog.
final id = identity.agentSessionId;
if (id == null) {
status.warning(
'No agent session id yet',
source: StatusSources.session,
sessionId: sessionId,
);
return;
}
// A clipboard write can throw for real (another process holds it on
// Windows; the host denies it). Unreported, the user gets neither the id
// nor a reason. Same contract as the panel's `Copy all` and `/session id`.
try {
await Clipboard.setData(ClipboardData(text: id));
} catch (e) {
status.failure(
'Could not copy session id',
error: e,
source: StatusSources.session,
sessionId: sessionId,
);
return;
}
status.info(
'Session id copied',
source: StatusSources.session,
detail: id,
sessionId: sessionId,
);
return;
}
if (selected != 'rename' || !context.mounted) return;
await handleClientCommand(
'/name',
Expand Down
18 changes: 18 additions & 0 deletions app/lib/store/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1237,6 +1237,8 @@ class Session {
this.parentId,
this.handoffReason,
this.origin,
this.agentSessionId,
this.transcriptPath,
this.queued = const [],
});

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

/// The underlying agent's own session id — pi's ACP `sessionId` (which is pi's
/// OWN session uuid, reused by `pi-acp`) or codex's `threadId`. Null for a
/// draft, for a back end with no native session concept, and for any server
/// older than SPEC-52 (D1).
final String? agentSessionId;

/// Absolute path to the transcript on the SERVER's host, resolved server-side
/// (D2/D3) — the app never derives it, because the slug algorithm is pi's and
/// the app cannot stat the server's filesystem to check itself. Null for codex
/// in P1 (D16) and whenever no file was found (D9).
final String? transcriptPath;

/// Messages submitted while the agent was busy that could not be steered into
/// the running turn (SPEC-35), oldest first. They are delivered one per idle
/// transition and can be cancelled until then.
Expand All @@ -1319,6 +1333,8 @@ class Session {
String? parentId,
String? handoffReason,
String? origin,
String? agentSessionId,
String? transcriptPath,
List<QueuedMessage>? queued,
}) => Session(
id: id,
Expand All @@ -1341,6 +1357,8 @@ class Session {
parentId: parentId ?? this.parentId,
handoffReason: handoffReason ?? this.handoffReason,
origin: origin ?? this.origin,
agentSessionId: agentSessionId ?? this.agentSessionId,
transcriptPath: transcriptPath ?? this.transcriptPath,
queued: queued ?? this.queued,
);
}
Expand Down
11 changes: 11 additions & 0 deletions app/lib/transport/codec.dart
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,24 @@ class WireCodec {
? j['handoffReason'] as String
: null,
origin: j['origin'] is String ? j['origin'] as String : null,
// SPEC-52 D1/D9: normalise `''` to null at the edge. A blank string is
// what a sloppy server sends for "no value", and it would render a copy
// affordance that copies nothing — the placeholder D9 forbids. Doing it
// here means nothing above this line has to think about it.
agentSessionId: _nonEmpty(j['agentSessionId']),
transcriptPath: _nonEmpty(j['transcriptPath']),
queued: decodeQueued(j['queued']),
),
);
}
return out;
}

/// A non-empty string, or null. Rejects non-strings too, so a malformed
/// snapshot degrades one field instead of failing the whole session list.
static String? _nonEmpty(Object? v) =>
(v is String && v.isNotEmpty) ? v : null;

/// Decode a session's `queued` array (SPEC-35). Absent/malformed entries yield
/// an empty queue rather than failing the whole snapshot: a session list is
/// too important to drop over a pending-message chip.
Expand Down
67 changes: 67 additions & 0 deletions app/lib/ui/composer/client_commands.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
library;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:phosphoricons_flutter/phosphoricons_flutter.dart';
Expand All @@ -20,6 +21,7 @@ import '../../status/status_event.dart';
import '../../status/status_providers.dart';
import '../widgets/sheet_header.dart';
import '../widgets/searchable_list_sheet.dart';
import '../session/session_identity.dart';
import '../../app/routes.dart';

typedef ClientCmdHandler =
Expand Down Expand Up @@ -264,6 +266,71 @@ final List<ClientCommand> clientCommands = <ClientCommand>[
);
},
),
ClientCommand(
name: 'session',
description: 'Show this session’s identity, or /session id to copy its id',
handler: (context, ref, {required sessionId, required arg}) async {
// WHY a CLIENT command and not sent to the agent (D7): pi's own `/session`
// is an agent command, so in makit's composer it would fall through to
// `store.sendMessage` and — mid-turn — land in the server's pending queue,
// executing only after the turn it was meant to help you hand off.
// Intercepting it here answers at 100% of a turn. This handler returning
// (via `handleClientCommand` matching) is the fix for that bug.
//
// Resolved before any await (SPEC-48 D3, enforced by
// `test/status/status_lifetime_test.dart`): `ref` dies with its widget.
final status = ref.status;
// `/session id` copies ONLY the bare agent session id (D6). The panel's
// `Copy all` is the "give me everything" job; this is "give me the id", so
// it must not emit the whole label:value payload.
if (arg == 'id') {
final id = ref.read(sessionIdentityProvider(sessionId)).agentSessionId;
if (id == null) {
// Say why rather than copying an empty string: a draft (or a back end
// with no native session concept) has no id to hand off yet.
status.warning(
'No agent session id yet',
source: StatusSources.session,
sessionId: sessionId,
);
return;
}
// A clipboard write can throw for real (another process holds it on
// Windows; the host denies it). Unreported, the user gets neither the id
// nor a reason — so the write is waited on, and only a write that landed
// is allowed to claim success. Same contract as the panel's `Copy all`.
try {
await Clipboard.setData(ClipboardData(text: id));
} catch (e) {
status.failure(
'Could not copy session id',
error: e,
source: StatusSources.session,
sessionId: sessionId,
);
return;
}
status.info(
'Session id copied',
source: StatusSources.session,
detail: id,
sessionId: sessionId,
);
return;
}
// Bare `/session` opens the panel. Presented as a bottom sheet
// (`desktop: false`) like the other client commands (`/model`,
// `/thinking`): the invocation comes from the composer, where a sheet is
// the established surface. `sessionId` is passed so the open panel watches
// and fills in live (D19).
if (!context.mounted) return;
await showSessionIdentity(
context: context,
desktop: false,
sessionId: sessionId,
);
},
),
ClientCommand(
name: 'name',
description: 'Rename this session (shown in the session list)',
Expand Down
Loading
Loading