Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b6d4301
AI-2199 spec: session rail and the tabless shell
alexeyzimarev Aug 25, 2026
885dd69
AI-2199 plan: session rail and tabless shell
alexeyzimarev Aug 25, 2026
d1a2c5b
AI-2199: additive title field on AgentStatusDto
alexeyzimarev Aug 25, 2026
23ee476
AI-2199: daemon stamps title from the launch prompt
alexeyzimarev Aug 25, 2026
462aaac
AI-2199: rail session row VM over shared status dots
alexeyzimarev Aug 25, 2026
4fe59ee
AI-2199: worktree level VM with shared collapse state
alexeyzimarev Aug 25, 2026
faf94b0
AI-2199: session rail projection over nested DynamicData groups
alexeyzimarev Aug 25, 2026
3b9eeaa
AI-2199: shell view state and rail wiring on the window VM
alexeyzimarev Aug 25, 2026
e64041e
AI-2199: tabless shell — Home/Sessions surfaces, session rail view, B…
alexeyzimarev Aug 25, 2026
4e9bc4a
AI-2199: rail highlight comes from a class style, not a local Background
alexeyzimarev Aug 25, 2026
711ec5d
AI-2199: delete the Agents grid — the rail supersedes it
alexeyzimarev Aug 25, 2026
cc48e63
AI-2199: compose and dispose the session rail with the window
alexeyzimarev Aug 25, 2026
1923303
AI-2199: dress the shell in the canvas palette
alexeyzimarev Aug 25, 2026
30879c8
AI-2199: Activity as a header flyout, type scale up a notch
alexeyzimarev Aug 25, 2026
b1548f4
AI-2199: bigger rail switcher, drop Home's placeholder buttons
alexeyzimarev Aug 25, 2026
1d55277
AI-2199: stroked chevron on worktree rows
alexeyzimarev Aug 25, 2026
3624659
AI-2199: the launcher IS the empty pane; Home goes dormant
alexeyzimarev Aug 25, 2026
b055513
AI-2199: model + effort chips on the composer; tenant in the rail footer
alexeyzimarev Aug 25, 2026
1ed0fb7
AI-2199: every worktree starts expanded
alexeyzimarev Aug 26, 2026
1bdb062
AI-2199: one searchable harness+model picker
alexeyzimarev Aug 26, 2026
c3662f7
AI-2199: the picker takes T3's shape
alexeyzimarev Aug 26, 2026
5149c3a
AI-2199: real vendor marks in the picker and chip
alexeyzimarev Aug 26, 2026
95ec81f
AI-2199: simplify pass over the branch
alexeyzimarev Aug 26, 2026
b9a394f
Merge remote-tracking branch 'origin/main' into alexeyzimarev/ai-2199…
alexeyzimarev Aug 26, 2026
c43b276
Strip Linear ids and change narration from comments
alexeyzimarev Aug 26, 2026
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
1,573 changes: 1,573 additions & 0 deletions docs/superpowers/plans/2026-08-25-ai2199-session-rail.md

Large diffs are not rendered by default.

288 changes: 288 additions & 0 deletions docs/superpowers/specs/2026-08-25-ai2199-session-rail-design.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/Capacitor.App/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<SolidColorBrush x:Key="KcapBorderBrush" Color="#2A3040" />
<SolidColorBrush x:Key="KcapTextBrush" Color="#F1F3F7" />
<SolidColorBrush x:Key="KcapMutedBrush" Color="#9299AA" />
<SolidColorBrush x:Key="KcapFaintBrush" Color="#6F7687" />
<SolidColorBrush x:Key="KcapAccentBrush" Color="#5BE0B3" />
<SolidColorBrush x:Key="KcapAccentDimBrush" Color="#173F36" />
<SolidColorBrush x:Key="KcapWarningBrush" Color="#F4B860" />
Expand All @@ -23,5 +24,14 @@
</Application.Resources>
<Application.Styles>
<FluentTheme />
<!-- Popup panels drawn on our tokens: the flyout presenter IS the panel (no inner Border
fighting the theme's own chrome). Opted into per flyout via FlyoutPresenterClasses. -->
<Style Selector="FlyoutPresenter.kcapPanel">
<Setter Property="Background" Value="{StaticResource KcapSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource KcapBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="0" />
</Style>
</Application.Styles>
</Application>
25 changes: 19 additions & 6 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ public partial class App : Application {
// construction path or a signature change to BuildAndShowMainWindow (AppStartupTests calls
// that method directly, with no Home argument).
HomeViewModel? _home;
// Same reasoning as _home just above: constructed INSIDE BuildAndShowMainWindow (over the
// SAME `service` instance) and read back off the built window's DataContext right after, so
// this field never needs a second construction path or a signature change to
// BuildAndShowMainWindow either.
SessionRailViewModel? _rail;
// Home's launch transport, held here because it is the one graph object that outlives a
// window rebuild (MainWindowCoordinator can build a second window over the same client) and
// owns a live HubConnection. Disposed after _home on both teardown paths — never before, or a
Expand Down Expand Up @@ -187,7 +192,7 @@ async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop) {
Console.Error.WriteLine($"kcap app failed to start: {ex}");
await _workspaceTeardown.DrainAsync();
await HandleStartupFailureAsync(
desktop, ex, _service, _shutdown, [_tray, _trayVm, _promptCoordinator, _consent, _activity, _home, _pause], _lifecycle, _lane);
desktop, ex, _service, _shutdown, [_tray, _trayVm, _promptCoordinator, _consent, _activity, _home, _rail, _pause], _lifecycle, _lane);
await DisposeLaunchClientAsync(); // after _home above — its only caller
// all already disposed above — never let a later OnShutdownRequested (e.g. Cmd+Q
// while the error window is up) dispose any of them a second time
Expand All @@ -203,6 +208,7 @@ await HandleStartupFailureAsync(
_pause = null;
_activity = null;
_home = null;
_rail = null;
_wizardAuth = null; // its attempt, if any, already settled through the wizard's own close path
_wizardImport = null; // same — any in-flight run already settled through the wizard's own close path
_wizardWindow = null;
Expand Down Expand Up @@ -311,7 +317,9 @@ void BuildDaemonGraph(
_coordinator = new MainWindowCoordinator(
() => BuildAndShowMainWindow(
service, _config, actions, notifier, ticker, _shutdown.Token, activity, lifecycle.StartActionAsync,
lifecycleStatus, launch, _navigation, _workspaceTeardown.Track, BuildWorkspace),
lifecycleStatus, launch, _navigation, _workspaceTeardown.Track, BuildWorkspace,
// The tenant slug the rail footer shows — profiles are named after it at sign-in.
tenantName: profiles?.Resolution?.ProfileName),
// Both close paths release the workspace: hide-to-tray keeps the window (and its
// attach) alive, a real close discards the window the next Show() would rebuild.
releaseWorkspace: window => (window.DataContext as MainWindowViewModel)?.CloseWorkspace());
Expand All @@ -326,6 +334,7 @@ void BuildDaemonGraph(
// the window's own DataContext rather than threading a new parameter through, so
// AppStartupTests' existing direct call to that method needs no change.
_home = (_coordinator.Window?.DataContext as MainWindowViewModel)?.Home;
_rail = (_coordinator.Window?.DataContext as MainWindowViewModel)?.Rail;

// LAST, deliberately (spec §9): anything above throwing lands in the catch with no
// tray icon ever created, leaving the error window as the only surface.
Expand Down Expand Up @@ -625,7 +634,7 @@ internal static MainWindow BuildAndShowMainWindow(
CancellationToken shutdownToken, ActivityViewModel activity, Func<CancellationToken, Task>? startAction = null,
IObservable<string?>? lifecycleStatus = null, ILaunchClient? launch = null,
NavigationGate? navigation = null, Action<Func<Task>>? trackWorkspaceTeardown = null,
Func<string, WorkspaceViewModel>? workspaceFactory = null) {
Func<string, WorkspaceViewModel>? workspaceFactory = null, string? tenantName = null) {
// Notifier is set on the WINDOW (spec §11 toast overlay), not the ViewModel — the toast
// is a View-level concern (WindowNotificationManager lives on MainWindow) independent of
// the VM's WhenActivated-scoped projections.
Expand All @@ -649,9 +658,13 @@ internal static MainWindow BuildAndShowMainWindow(
openSession: agentId => vm?.OpenSession(agentId),
navigationGeneration: () => vm?.NavigationGeneration ?? 0,
openSessionIfCurrent: (agentId, generation) => vm?.OpenSessionIfCurrent(agentId, generation));
// Same knot as home above, over the SAME `service` instance — its own openSession
// callback closes over `vm`, not a local, so no two-step forward-declaration is needed.
var rail = new SessionRailViewModel(service, openSession: agentId => vm?.OpenSession(agentId));
vm = new MainWindowViewModel(
service, actions, ticker, shutdownToken, activity, startAction, lifecycleStatus, home: home,
navigation: navigation, trackWorkspaceTeardown: trackWorkspaceTeardown, workspaceFactory: workspaceFactory);
service, shutdownToken, activity, startAction, lifecycleStatus, home: home,
navigation: navigation, trackWorkspaceTeardown: trackWorkspaceTeardown, workspaceFactory: workspaceFactory,
rail: rail, tenantName: tenantName);
var window = new MainWindow {
DataContext = vm,
Notifier = notifier,
Expand Down Expand Up @@ -1104,7 +1117,7 @@ async Task DisposeAndShutdownAsync() {
// disposed one. A resolve already in flight was cancelled by _shutdown at the top of
// OnShutdownRequested and settles on the ViewModel's silent-abort path.
await DisposeUiThenConfirmShutdownAsync(
[_tray, _trayVm, _promptCoordinator, _consent, _activity, _home, _pause],
[_tray, _trayVm, _promptCoordinator, _consent, _activity, _home, _rail, _pause],
DisposeLifecycleAndServiceAsync, () => _shutdownConfirmed = true, desktop, _exitCode);
} else {
await DisposeLifecycleAndServiceAsync();
Expand Down
60 changes: 60 additions & 0 deletions src/Capacitor.App/Services/HostedHarnessCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ namespace Capacitor.App.Services;

public sealed record HarnessOption(string Vendor, string Label, string TransportFamily, bool Available);

/// One curated model suggestion for the combined harness+model picker. Slug is what the wire
/// carries verbatim; Label is the picker's friendly name.
public sealed record ModelChoice(string Slug, string Label);

/// The picker's vendor list. Availability comes from what the daemon advertises
/// (DaemonInfoDto.SupportedVendors), never from a version check — a vendor auto-update must not
/// silently withdraw a harness. A vendor the daemon advertises but this build has never heard of
Expand Down Expand Up @@ -53,6 +57,62 @@ public static IReadOnlyList<HarnessOption> Build(string[]? supportedVendors) {
return options;
}

// Curated model suggestions per vendor — a hardcoded catalog, deliberately (T3 Code ships
// Claude's exactly this way; it lists Codex's live from `codex app-server`, which our daemon
// could do too once a model-list IPC exists). The picker always offers "vendor default" and a
// typed custom id besides these, so catalog drift can never block a launch.
static readonly Dictionary<string, ModelChoice[]> ModelChoices = new(StringComparer.OrdinalIgnoreCase) {
["claude"] = [
new("claude-fable-5", "Claude Fable 5"),
new("claude-opus-5", "Claude Opus 5"),
new("claude-sonnet-5", "Claude Sonnet 5"),
new("claude-haiku-4-5", "Claude Haiku 4.5"),
],
["codex"] = [
new("gpt-5.6", "GPT-5.6"),
],
};

/// The curated suggestions for a vendor; empty for one without a list (picker then offers
/// only the default + custom rows).
public static IReadOnlyList<ModelChoice> ModelChoicesFor(string vendor) =>
ModelChoices.TryGetValue(vendor, out var models) ? models : [];

/// Chip wording for a model selection: the curated label when known, the raw slug otherwise,
/// "default" for the "" sentinel.
public static string ModelLabelFor(string vendor, string model) =>
string.IsNullOrWhiteSpace(model)
? "default"
: ModelChoicesFor(vendor).FirstOrDefault(m => string.Equals(m.Slug, model, StringComparison.OrdinalIgnoreCase))?.Label ?? model;

/// The effort ladder the daemon passes through verbatim (codex maps max→xhigh itself); the
/// picker's Default entry (null on the wire) hands the choice back to the harness. Lives here
/// beside the model catalog so the launch vocabularies have one authority.
public static readonly IReadOnlyList<string> EffortLadder = ["low", "medium", "high", "xhigh"];

// Monogram + tint per vendor: the glyph is the fallback where the view layer has no brand
// mark (VendorIcons); the tint colors both. Monochrome brands render in the near-white text
// color; claude/gemini keep their brand hues. Beside the labels/families so adding a vendor
// means one file, not three.
static readonly Dictionary<string, (string Glyph, string Color)> VendorTiles = new(StringComparer.OrdinalIgnoreCase) {
["claude"] = ("✳", "#D97757"),
["codex"] = ("Cx", "#F1F3F7"),
["cursor"] = ("Cu", "#F1F3F7"),
["copilot"] = ("Cp", "#F1F3F7"),
["gemini"] = ("Ge", "#7BA7F7"),
["kiro"] = ("Ki", "#B78BF7"),
["opencode"] = ("Oc", "#F1F3F7"),
["antigravity"] = ("An", "#F4B860"),
["pi"] = ("π", "#A994FF"),
};

/// Glyph + tint for a vendor token; an unmapped token gets its first letter in neutral grey
/// (the daemon is the authority on what it can host — see Build's same rule).
public static (string Glyph, string Color) TileFor(string vendor) =>
VendorTiles.TryGetValue(vendor, out var tile)
? tile
: (vendor.Length > 0 ? vendor[..1].ToUpperInvariant() : "?", "#9299AA");

/// Display label for a vendor token: the option's Label when the list carries one, the raw
/// token otherwise (before the first daemon snapshot, or a token this build has never heard
/// of). Shared by the harness chip and the repository menu's remembered-harness pill.
Expand Down
9 changes: 7 additions & 2 deletions src/Capacitor.App/Services/ILaunchClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

namespace Capacitor.App.Services;

public sealed record LaunchRequest(string DaemonName, string RepoPath, string Vendor, string? Prompt);
/// Model "" and Effort null both mean "vendor default" — the wire's own conventions (the server
/// rejects a null model; the daemon treats whitespace as no request).
public sealed record LaunchRequest(
string DaemonName, string RepoPath, string Vendor, string? Prompt,
string Model = "", string? Effort = null);

public sealed record LaunchOutcome(bool Started, string? AgentId, string? Error);

Expand Down Expand Up @@ -46,7 +50,8 @@ public static class LaunchPayload {
public static LaunchAgentRequestV2Payload For(LaunchRequest r) => new() {
DaemonName = r.DaemonName,
Prompt = string.IsNullOrWhiteSpace(r.Prompt) ? null : r.Prompt,
Model = "", // vendor default; the server rejects null
Model = r.Model.Trim(), // "" = vendor default; the server rejects null
Effort = string.IsNullOrWhiteSpace(r.Effort) ? null : r.Effort,
RepoPath = r.RepoPath,
Vendor = r.Vendor,
};
Expand Down
4 changes: 2 additions & 2 deletions src/Capacitor.App/Services/UiTicker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ public sealed class UiTicker : ITicker {
// forces the subscription — and therefore Interval's first Schedule call — onto the real UI
// dispatcher via AvaloniaScheduler's zero-dueTime Dispatcher.UIThread.Post path.
//
// No StartWith: a row's immediate first value is the OAPH's initialUptime argument
// (AgentRowViewModel), so a StartWith would only re-emit the identical string. The scheduler is
// No StartWith: a consuming row's immediate first value is its own OAPH's initialUptime
// argument, so a StartWith would only re-emit the identical string. The scheduler is
// captured NOW (Rx operators take a scheduler by value, not a live reference to
// RxSchedulers.MainThreadScheduler) and RefCount defers the connection until a row subscribes —
// which is what lets a test construct this ticker inside AvaloniaSession.WithImmediateRxScheduler
Expand Down
95 changes: 0 additions & 95 deletions src/Capacitor.App/ViewModels/AgentRowViewModel.cs

This file was deleted.

Loading