Files
clide/lib/builtin/claude/src/claude_session_host.dart
T
jpmschweitzerandClaude Opus 4.8 6d0ebab721 chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)
Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:11:53 +02:00

146 lines
4.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/widgets/widgets.dart';
import 'package:flutter/widgets.dart';
import 'claude_pane.dart';
/// Hosts the primary Claude pane plus N user-spawned secondary
/// sessions per D-41. Uses [MultitabPane] for the tab strip
/// (drag-reorder, close ×, + button) and [IndexedStack]-mode
/// keep-alive so switching tabs doesn't tear down the underlying
/// PTY-backed terminal.
class ClaudeSessionHost extends StatefulWidget {
const ClaudeSessionHost({super.key});
@override
State<ClaudeSessionHost> createState() => ClaudeSessionHostState();
}
class ClaudeSessionHostState extends State<ClaudeSessionHost> {
static const _primaryId = 'primary';
late final MultitabController<_Session> _controller;
int _nextSecondary = 1;
StreamSubscription<ProjectOpened>? _projectSub;
String? _projectRoot;
@override
void initState() {
super.initState();
_controller = MultitabController<_Session>(
initial: [
MultitabEntry<_Session>(
id: _primaryId,
title: 'primary',
payload: const _Session(isPrimary: true),
// Primary persists across clide restarts and never gets a
// close affordance (D-41).
closeable: false,
reorderable: false,
),
],
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_projectSub ??= ClideKernel.of(context).events.on<ProjectOpened>().listen(_onProjectChanged);
}
/// Reset to a lone primary tab when the workspace is switched in place
/// (T-269): the old repo's secondaries/forks don't belong in the new
/// workspace. Removing them disposes their panes, which close their sessions
/// through the orchestrator. The primary tab stays and rebinds itself.
void _onProjectChanged(ProjectOpened e) {
final prev = _projectRoot;
_projectRoot = e.path;
if (prev == null || prev == e.path) return; // initial open / no change
if (!mounted) return;
final stale = _controller.entries.where((x) => x.id != _primaryId).map((x) => x.id).toList();
if (stale.isEmpty) return;
setState(() {
for (final id in stale) {
_controller.remove(id);
}
_nextSecondary = 1;
});
}
@override
void dispose() {
_projectSub?.cancel();
_controller.dispose();
super.dispose();
}
/// Ids of the open tabs, oldest-first. Exposed for tests that assert the
/// host resets to a lone primary on an in-place workspace switch (T-269).
@visibleForTesting
List<String> get tabIds => _controller.entries.map((e) => e.id).toList();
/// Public entry point used by the `claude.new-secondary` command.
void addSecondary() {
final index = _nextSecondary++;
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'session $index',
payload: _Session(isPrimary: false, secondaryIndex: index),
),
);
}
/// Open a new pane as a fork of [sourceClaudeSessionId] (T-172).
///
/// The fork pane is a secondary tab seeded with `--resume <source>
/// --fork-session` so the branch diverges into its own claude session
/// without touching the original.
void addFork(String sourceClaudeSessionId) {
final index = _nextSecondary++;
_controller.add(
MultitabEntry<_Session>(
id: 'secondary-$index',
title: 'fork $index',
payload: _Session(isPrimary: false, secondaryIndex: index, forkSourceId: sourceClaudeSessionId),
),
);
}
@override
Widget build(BuildContext context) {
return MultitabPane<_Session>(
controller: _controller,
keepAlive: true,
onAddRequested: addSecondary,
bodyBuilder: (ctx, entry) {
final s = entry.payload;
return ClaudePane(
isPrimary: s.isPrimary,
secondaryIndex: s.secondaryIndex,
forkSourceId: s.forkSourceId,
onFork: addFork,
// The MultitabPane already provides the tab strip header;
// suppressing the ClaudePane's own chrome avoids a double row.
showChrome: false,
// Only the visible sub-tab publishes to the status-bar slot.
active: entry.id == _controller.activeId,
);
},
);
}
}
class _Session {
const _Session({required this.isPrimary, this.secondaryIndex, this.forkSourceId});
final bool isPrimary;
final int? secondaryIndex;
/// When non-null, spawn this pane as a fork of the given claude session id
/// (T-172). Forwarded to [ClaudePane.forkSourceId].
final String? forkSourceId;
}