Compare commits

...
12 Commits
Author SHA1 Message Date
Mika Kuns 87dbb117d7 fix: Interactive sessions no longer Instantly start working
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 45s
2026-09-28 14:33:06 +02:00
Mika Kuns 0f82fa67e1 feat(claude-do): merge UI-Prozess loggt auf Platte (ui-.log) + fehlgeschlagene Hub-
ClaudeDo-Task: d2ae3831-733f-4d40-93d7-70c93da11590
2026-09-28 13:39:18 +02:00
Mika Kuns 9f2e63d337 feat(claude-do): merge Ordner in Ordner erlauben (beliebig tief, nur per Drag & Dro
ClaudeDo-Task: dc3d5f8cf9e74821ba99b18e443fe5f5
2026-09-28 13:37:19 +02:00
Mika Kuns 3009ff0f8f fix(claude-do): snake_case Serilog tokens + no Warning-log on cancelled hub calls
Message-template tokens must be snake_case (repo rule) -- fixes 4 PascalCase
tokens introduced in 57dd6766. TryInvokeAsync's swallowing catch also no
longer logs a Warning for OperationCanceledException, which is routine
(e.g. worker shutdown/reconnect), not a failure worth a trace.

ClaudeDo-Task: d2ae3831-733f-4d40-93d7-70c93da11590
2026-09-28 13:32:38 +02:00
Mika KunsandClaude Sonnet 5 0dac047c46 feat(lists): allow nested sidebar folders via drag and drop
Folders can now contain other folders, to arbitrary depth, via a
self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as
lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a
folder onto another folder's header middle moves it in, the outer
quarters or any top-level/in-folder drop insert it beside a target.
Dropping a folder into itself or one of its own subfolders is silently
ignored. Deleting a folder releases its lists and subfolders back to
the top level, unchanged from the existing list behavior.

ListFolderNavItemViewModel.Lists became Children (object, mixing lists
and subfolders, mirroring TopLevel) so the existing implicit, type-matched
DataTemplates render nested folders recursively without new XAML, and each
nesting level gets its indent for free from the folder template's own
margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts
now walk the tree instead of assuming one level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:31:26 +02:00
Mika Kuns 57dd676672 feat(claude-do): UI-Prozess loggt auf Platte (ui-.log) + fehlgeschlagene Hub-
## Problem
Beim DeleteTask-Bug (Fix 352aaa52) gab es null Spuren: Der UI-Prozess (ClaudeDo.App) hat gar kein File-Logging (weder Serilog noch `Microsoft.Extensions.Logging` sind in App/Ui referenziert), und im Worker loggt SignalR fehlgeschlagene Hub-Invocations nur auf Debug (`Microsoft.AspNetCore` steht in `src/ClaudeDo.Worker/Program.cs` Z. 60 auf Warning). Die Ursache ließ sich nur finden, ind

ClaudeDo-Task: d2ae3831-733f-4d40-93d7-70c93da11590
2026-09-28 13:27:21 +02:00
mika kuns 352aaa525b fix(worker): DeleteTask aus der UI schlug immer mit generischem Serverfehler fehl
Der CancellationToken-Parameter auf der nicht-streamenden Hub-Methode liess SignalR jede Invocation vor dem Methodenrumpf abbrechen. Die Tests riefen den Hub direkt auf und sahen das nicht; neuer Reflection-Guard verbietet CancellationToken auf allen nicht-streamenden Hub-Methoden.
2026-09-28 13:02:47 +02:00
Mika Kuns c8b2ad046a chore(claude-do): merge Mission Control: Recent Sessions mit verschachtelten Handoff
ClaudeDo-Task: e51c2b8a-614b-4e4f-8e93-6f90a7ba9960
2026-09-28 12:24:39 +02:00
Mika Kuns d3db4455fa feat(ui): Mission Control Recent Sessions mit verschachtelten Handoffs
Neue Tabelle mission_control_sessions (Migration AddMissionControlSessions), befuellt vom
InteractiveLaunchSpecService bei Task-, Ad-hoc- und Handoff-Kacheln. Flyout im Mission-Control-Header
listet die letzten 14 Tage / 50 Sessions, Handoffs eingerueckt unter ihrer Ursprungs-Session, mit
Fortsetzen (--resume im gespeicherten cwd) und Brief anzeigen.
2026-09-28 12:11:21 +02:00
Mika Kuns 216620c52e chore(claude-do): merge Mission Control: allgemeiner Session-Handoff an eine frische
ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
2026-09-28 11:58:14 +02:00
Mika Kuns 08d1797ead chore(claude-do): Mission Control: allgemeiner Session-Handoff an eine frische
## Ziel

Jede Mission-Control-Session (Ad-hoc UND Task-Session) soll ihre Arbeit an eine frische Session
uebergeben koennen: "gib das ab an eine neue Session". Die alte Kachel wird geschlossen, an
derselben Position oeffnet eine frische Session im selben cwd, die mit einem Brief startet, den
die abgebende Session selbst geschrieben hat.

Motivation: eine lange Session mit vollem/verrottetem Kontex

ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
2026-09-28 11:57:37 +02:00
mika kuns e0a1448f44 feat(ui): Mission-Control-Kacheln behalten Position und Identitaetsfarbe ueber Handoffs
Ein Handoff schloss die alte Pane und haengte die neue hinten an -- die Kachel
sprang ans Ende und das ganze Grid ordnete sich neu. Die neue Pane wird jetzt am
Index der alten eingesetzt.

Dazu bekommt jede Kachel eine aus IdentityKey (Default: TaskId) abgeleitete Farbe:
3px-Streifen links am Tile, Punkt im Focus-Mode-Tab. Kein State, keine Persistenz --
derselbe Task faerbt nach App-Neustart gleich, und ein Handoff erbt die Farbe, weil
er dieselbe TaskId behaelt. Nur kuehle Hues, damit nichts als Status gelesen wird.
2026-09-28 08:56:50 +02:00
49 changed files with 4027 additions and 134 deletions
+42
View File
@@ -97,6 +97,48 @@ Net effect: reopening an interactive session for a task (pane closed, process ki
resumes the same claude conversation, because the id was committed to the DB before the previous resumes the same claude conversation, because the id was committed to the DB before the previous
launch even started. launch even started.
## Session handoff (`handoff_session`)
Task and ad-hoc tiles can hand off to a fresh session in the same tile. The **paneId is the pane's
`IdentityKey`**: the task id for a task tile, a GUID minted by `OpenAdHocConPtySessionAsync` for an
ad-hoc one.
**The model never passes an id** (it guesses GUIDs). Instead, every such spec gets
`--mcp-config ~/.claudeDo/pane-sessions/<paneId>/mcp.json`, which adds a second entry,
`claudedo_pane`. It points at the same external MCP, with headers `X-ClaudeDo-Pane` and
`X-ClaudeDo-Session` (plus `X-ClaudeDo-Key` when one is configured). An `AddListToolsFilter`
(`SessionHandoffMcpTools.FilterByPane`) splits the tool list by that header:
- `claudedo_pane` lists only `handoff_session`;
- the plain `claudedo` entry lists everything except `handoff_session`.
The hint (`handoff-hint.md`, via `--append-system-prompt-file`) says when to call
`mcp__claudedo_pane__handoff_session(brief)` and what goes into the brief, and contains no ids. Ad-hoc
tiles get a pre-assigned `--session-id` so the header has one. With `ExternalMcpPort = 0` the tile
gets neither the entry nor the hint.
`handoff_session(brief)` writes `pane-sessions/<paneId>/handoffs/<guid>/brief.md` (first line:
`Handed off from session <id>`) and broadcasts `SessionHandoffRequested(paneId, briefPath)`. The
worker has no pane registry: the UI looks up the pane by `IdentityKey`, takes its `Cwd` (from the
resolved launch spec) and its `TaskId`, and closes it. It then reopens the tile at the same slot
with the same `IdentityKey` via `GetSessionHandoffLaunchSpec` → `BuildHandoffAsync`. For a task
tile, the new session id replaces `InteractiveSessionId`, so reopening the task resumes the
handed-off session. The old transcript remains under `~/.claude/projects/`.
## Recent sessions
Every task, ad-hoc and handoff tile launch (only those — `paneId != null`) records its session in
`mission_control_sessions` from inside `InteractiveLaunchSpecService`, which is where the session id
is known. A handoff's predecessor comes from the brief's first line
(`SessionHandoffMcpTools.ReadHandedOffFrom`), and it inherits the predecessor's title and task.
The clock button in the Mission Control header opens a flyout (`RefreshRecentSessionsAsync`, last 14
days / 50 rows, read straight from the DB). `NestHandoffs` groups each chain under its origin.
- **Resume**: a task's *current* `InteractiveSessionId` reopens the task tile (deduped by TaskId).
Any other session gets a fresh ad-hoc tile via `GetResumeSessionLaunchSpec` → `BuildResumeAsync`
(`--resume <id>` in the recorded cwd, handoff hint included).
- **Brief**: `ShellOpen.Path(brief.md)`, which opens the file in the default editor.
- Not guarded: resuming a session that is still live in another ad-hoc tile, since panes don't
track their session id.
## List handler ("Let Claude handle it") ## List handler ("Let Claude handle it")
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
+1
View File
@@ -52,6 +52,7 @@ public partial class App : Application
Dispatcher.UIThread.UnhandledException += (_, e) => Dispatcher.UIThread.UnhandledException += (_, e) =>
{ {
e.Handled = true; e.Handled = true;
Serilog.Log.Error(e.Exception, "Unhandled exception on the UI dispatcher");
shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message)); shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message));
}; };
+2
View File
@@ -26,6 +26,8 @@
</PackageReference> </PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+33
View File
@@ -13,6 +13,7 @@ using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings; using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System; using System;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
@@ -20,6 +21,7 @@ using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks;
namespace ClaudeDo.App; namespace ClaudeDo.App;
@@ -34,6 +36,35 @@ sealed class Program
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App"); SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
var logRoot = Path.Combine(Paths.AppDataRoot(), "logs");
Directory.CreateDirectory(logRoot);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
Path.Combine(logRoot, "ui-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true)
.CreateLogger();
var version = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "0.0.0";
Log.Information("ClaudeDo.App starting (version: {version})", version);
// Last-resort net: any exception that misses every other handler still gets one
// Fatal line in the log before the process dies.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (is_terminating: {is_terminating})", e.IsTerminating);
Log.CloseAndFlush();
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Log.Error(e.Exception, "Unobserved task exception");
e.SetObserved();
};
var services = BuildServices(); var services = BuildServices();
using (var scope = services.CreateScope()) using (var scope = services.CreateScope())
@@ -62,6 +93,8 @@ sealed class Program
{ {
/* best effort on shutdown */ /* best effort on shutdown */
} }
Log.CloseAndFlush();
} }
} }
+3 -1
View File
@@ -14,7 +14,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below. - Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, FolderId - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, FolderId
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP. - `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir, **one level** (folders never nest). Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`. - **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, ParentFolderId, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir. `ParentFolderId` (self-FK, `ON DELETE SET NULL`) lets folders nest arbitrarily deep — drag-and-drop only, no UI to pick a parent otherwise. Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section. - **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
- ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one. - ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one.
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`) - **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
@@ -24,6 +24,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date) - **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments` - **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
- **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`. - **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`.
- **MissionControlSessionEntity** — SessionId (PK), Cwd, TaskId (no FK), Title, StartedAt, PredecessorSessionId, BriefPath → `mission_control_sessions`. Index behind Mission Control's "Recent sessions" flyout; written only by `InteractiveLaunchSpecService` (task/ad-hoc/handoff tile launches), insert-if-absent → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files - **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
### AppSettingsEntity ### AppSettingsEntity
@@ -120,6 +121,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
- `task_attachments` - `task_attachments`
- `task_dependencies` - `task_dependencies`
- `list_folders` - `list_folders`
- `mission_control_sessions`
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now, a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now,
+1
View File
@@ -54,6 +54,7 @@ public class ClaudeDoDbContext : DbContext
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>(); public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>(); public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>(); public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
public DbSet<MissionControlSessionEntity> MissionControlSessions => Set<MissionControlSessionEntity>();
private static readonly ValueConverter<DateTime, DateTime> UtcConverter = private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
@@ -17,7 +17,14 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolder
builder.Property(f => f.Icon).HasColumnName("icon"); builder.Property(f => f.Icon).HasColumnName("icon");
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false); builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id");
builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort"); builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort");
builder.HasIndex(f => f.ParentFolderId).HasDatabaseName("idx_list_folders_parent");
builder.HasOne<ListFolderEntity>()
.WithMany()
.HasForeignKey(f => f.ParentFolderId)
.OnDelete(DeleteBehavior.SetNull);
} }
} }
@@ -0,0 +1,22 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class MissionControlSessionEntityConfiguration : IEntityTypeConfiguration<MissionControlSessionEntity>
{
public void Configure(EntityTypeBuilder<MissionControlSessionEntity> builder)
{
builder.ToTable("mission_control_sessions");
builder.HasKey(s => s.SessionId);
builder.Property(s => s.SessionId).HasColumnName("session_id").ValueGeneratedNever();
builder.Property(s => s.Cwd).HasColumnName("cwd").IsRequired();
builder.Property(s => s.TaskId).HasColumnName("task_id");
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
builder.Property(s => s.StartedAt).HasColumnName("started_at").IsRequired();
builder.Property(s => s.PredecessorSessionId).HasColumnName("predecessor_session_id");
builder.Property(s => s.BriefPath).HasColumnName("brief_path");
builder.HasIndex(s => s.StartedAt);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddMissionControlSessions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "mission_control_sessions",
columns: table => new
{
session_id = table.Column<string>(type: "TEXT", nullable: false),
cwd = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: true),
title = table.Column<string>(type: "TEXT", nullable: false),
started_at = table.Column<DateTime>(type: "TEXT", nullable: false),
predecessor_session_id = table.Column<string>(type: "TEXT", nullable: true),
brief_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_mission_control_sessions", x => x.session_id);
});
migrationBuilder.CreateIndex(
name: "IX_mission_control_sessions_started_at",
table: "mission_control_sessions",
column: "started_at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mission_control_sessions");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddNestedListFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "parent_folder_id",
table: "list_folders",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx_list_folders_parent",
table: "list_folders",
column: "parent_folder_id");
migrationBuilder.AddForeignKey(
name: "FK_list_folders_list_folders_parent_folder_id",
table: "list_folders",
column: "parent_folder_id",
principalTable: "list_folders",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_list_folders_list_folders_parent_folder_id",
table: "list_folders");
migrationBuilder.DropIndex(
name: "idx_list_folders_parent",
table: "list_folders");
migrationBuilder.DropColumn(
name: "parent_folder_id",
table: "list_folders");
}
}
}
@@ -389,6 +389,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string>("ParentFolderId")
.HasColumnType("TEXT")
.HasColumnName("parent_folder_id");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -397,12 +401,54 @@ namespace ClaudeDo.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ParentFolderId")
.HasDatabaseName("idx_list_folders_parent");
b.HasIndex("SortOrder") b.HasIndex("SortOrder")
.HasDatabaseName("idx_list_folders_sort"); .HasDatabaseName("idx_list_folders_sort");
b.ToTable("list_folders", (string)null); b.ToTable("list_folders", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b =>
{
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<string>("BriefPath")
.HasColumnType("TEXT")
.HasColumnName("brief_path");
b.Property<string>("Cwd")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cwd");
b.Property<string>("PredecessorSessionId")
.HasColumnType("TEXT")
.HasColumnName("predecessor_session_id");
b.Property<DateTime>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("SessionId");
b.HasIndex("StartedAt");
b.ToTable("mission_control_sessions", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -988,6 +1034,14 @@ namespace ClaudeDo.Data.Migrations
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null)
.WithMany()
.HasForeignKey("ParentFolderId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
+4 -1
View File
@@ -4,7 +4,7 @@ namespace ClaudeDo.Data.Models;
/// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on /// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on
/// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to /// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to
/// list", the queue picker, repo import) would otherwise have to filter folders out. /// list", the queue picker, repo import) would otherwise have to filter folders out.
/// One level only — a folder holds lists, never other folders. /// Folders nest arbitrarily deep via <see cref="ParentFolderId"/>.
/// </summary> /// </summary>
public sealed class ListFolderEntity public sealed class ListFolderEntity
{ {
@@ -17,4 +17,7 @@ public sealed class ListFolderEntity
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is // Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
// already being read for the folder itself. // already being read for the folder itself.
public bool IsCollapsed { get; set; } public bool IsCollapsed { get; set; }
// Parent folder, or null for a top-level folder. Self-FK, ON DELETE SET NULL — deleting a
// folder releases its subfolders to the top level, same as it does for its lists.
public string? ParentFolderId { get; set; }
} }
@@ -0,0 +1,27 @@
namespace ClaudeDo.Data.Models;
/// <summary>One claude session started in a Mission Control tile (ad-hoc, task or handoff) — the
/// index behind "Recent sessions". The transcript itself stays with Claude Code.</summary>
public sealed class MissionControlSessionEntity
{
/// <summary>The claude session id (`--session-id` / `--resume`).</summary>
public string SessionId { get; init; } = string.Empty;
/// <summary>Working directory the session ran in — a resume must start there.</summary>
public string Cwd { get; set; } = string.Empty;
/// <summary>Task the tile belonged to; null for an ad-hoc tile. No FK: the row outlives the task.</summary>
public string? TaskId { get; set; }
/// <summary>Tile title at start (task title or folder name; a handoff inherits its predecessor's).</summary>
public string Title { get; set; } = string.Empty;
/// <summary>UTC time the session was first launched.</summary>
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
/// <summary>Session this one was handed off from; null unless it is a handoff session.</summary>
public string? PredecessorSessionId { get; set; }
/// <summary>brief.md the session started from; non-null exactly for handoff sessions.</summary>
public string? BriefPath { get; set; }
}
@@ -135,6 +135,14 @@ public sealed class ListRepository
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct); .ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
} }
/// <summary>Moves a folder under <paramref name="parentFolderId"/>, or to the top level when null.</summary>
public async Task SetFolderParentAsync(string folderId, string? parentFolderId, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.ParentFolderId, parentFolderId), ct);
}
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary> /// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default) public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
{ {
@@ -0,0 +1,34 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
/// <summary>Reads and writes <c>mission_control_sessions</c>.</summary>
public sealed class MissionControlSessionRepository
{
private readonly ClaudeDoDbContext _context;
public MissionControlSessionRepository(ClaudeDoDbContext context) => _context = context;
/// <summary>Inserts the session unless it is already recorded — a resumed task session keeps
/// its original row and start time.</summary>
public async Task RecordAsync(MissionControlSessionEntity session, CancellationToken ct = default)
{
if (await _context.MissionControlSessions.AnyAsync(s => s.SessionId == session.SessionId, ct)) return;
_context.MissionControlSessions.Add(session);
await _context.SaveChangesAsync(ct);
}
/// <summary>The recorded session, or null.</summary>
public async Task<MissionControlSessionEntity?> GetAsync(string sessionId, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct);
/// <summary>Sessions started since <paramref name="sinceUtc"/>, newest first, at most <paramref name="max"/>.</summary>
public async Task<IReadOnlyList<MissionControlSessionEntity>> ListRecentAsync(
DateTime sinceUtc, int max, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking()
.Where(s => s.StartedAt >= sinceUtc)
.OrderByDescending(s => s.StartedAt)
.Take(max)
.ToListAsync(ct);
}
+7 -1
View File
@@ -378,6 +378,12 @@
"openSession": "Sitzung öffnen", "openSession": "Sitzung öffnen",
"homeFolder": "Home (Schnellsitzung)", "homeFolder": "Home (Schnellsitzung)",
"browseForFolder": "Ordner wählen…", "browseForFolder": "Ordner wählen…",
"recentSessions": "Letzte Sitzungen",
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
"resumeSession": "Fortsetzen",
"showBrief": "Brief",
"handoffSession": "Übergabe",
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
"focusMode": "Fokus", "focusMode": "Fokus",
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung hart beenden", "closeSession": "Sitzung hart beenden",
@@ -879,7 +885,7 @@
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." }, "listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen und Unterordner darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "stashAndMergeConfirm": "Das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.\nStashen, mergen und danach wiederherstellen?", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "stashAndMergeConfirm": "Das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.\nStashen, mergen und danach wiederherstellen?", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+7 -1
View File
@@ -378,6 +378,12 @@
"openSession": "Open session", "openSession": "Open session",
"homeFolder": "Home (quick session)", "homeFolder": "Home (quick session)",
"browseForFolder": "Choose folder…", "browseForFolder": "Choose folder…",
"recentSessions": "Recent sessions",
"recentSessionsEmpty": "No sessions in the last 14 days",
"resumeSession": "Resume",
"showBrief": "Brief",
"handoffSession": "Handoff",
"briefOpenFailed": "Couldn't open the brief: {0}",
"focusMode": "Focus", "focusMode": "Focus",
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Kill session", "closeSession": "Kill session",
@@ -879,7 +885,7 @@
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." }, "listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists and subfolders inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "stashAndMergeConfirm": "The target working directory has uncommitted changes.\nStash them, merge, and restore them afterwards?", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "stashAndMergeConfirm": "The target working directory has uncommitted changes.\nStash them, merge, and restore them afterwards?", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
+1 -1
View File
@@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`. - **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`. - **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Lists`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. `Folders`/`RootLists` are `OfType` **projections** of it, not collections to keep in sync. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row)` resolves which collection renders it (non-generic `IList`, since the top level is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same list template by walking up to the UserControl. A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of its children. `DropAsync(source, target, placeBelow)` handles all four source/target combinations and doubles as the move: a drop position implies a container, so dropping a list onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`. A folder dropped onto a row inside a folder is ignored; folders never nest. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`, one `sort_order` sequence spanning **both tables**. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder. - **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Children`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. Folders nest arbitrarily deep — a folder's `Children` mixes lists and subfolders the same way `TopLevel` mixes folders and folder-less lists, via `ParentFolderId`. `Folders` is a **recursive** `OfType` projection over the whole tree (not just the top level); `RootLists` stays a `TopLevel`-only projection. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row-or-folder)` resolves which collection renders it (non-generic `IList`, since every container is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same templates recursively by walking up to the UserControl (which is also what indents nested folders: each level adds its template's fixed margin). A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of all descendant lists, recursive. `DropAsync(source, target, placeBelow)` handles list and folder sources against any target and doubles as the move: a drop position implies a container, so dropping a row onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`/`ParentFolderId`. A folder dropped onto or next to a target inside its own subtree is silently ignored (self/descendant guard), never nested into itself. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead; also takes a folder source now); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`/`SetFolderParentAsync`, one `sort_order` sequence spanning **both tables**, walked depth-first. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell. - **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`. - **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file. - Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
+1
View File
@@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" /> <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" /> <PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
<PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" /> <PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" />
<PackageReference Include="Serilog" Version="4.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+11
View File
@@ -104,6 +104,16 @@
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" /> <SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
<SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" /> <SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" />
<!-- Mission Control tile identity colours. Cool hues only, on purpose: the warm/green half of
the wheel is taken by the status semantics above, and an identity strip must never read as
"running"/"review"/"failed". -->
<SolidColorBrush x:Key="Identity1Brush" Color="#5FA8C8" />
<SolidColorBrush x:Key="Identity2Brush" Color="#4FA396" />
<SolidColorBrush x:Key="Identity3Brush" Color="#7E88CC" />
<SolidColorBrush x:Key="Identity4Brush" Color="#A57BB5" />
<SolidColorBrush x:Key="Identity5Brush" Color="#C77FA0" />
<SolidColorBrush x:Key="Identity6Brush" Color="#5C78B8" />
<!-- Merge editor (3-pane conflict resolver) block tints --> <!-- Merge editor (3-pane conflict resolver) block tints -->
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) --> <SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) --> <SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
@@ -148,6 +158,7 @@
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness> <Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
<Thickness x:Key="IslandBodyPadding">14</Thickness> <Thickness x:Key="IslandBodyPadding">14</Thickness>
<Thickness x:Key="WindowBodyPadding">14</Thickness> <Thickness x:Key="WindowBodyPadding">14</Thickness>
<Thickness x:Key="NestedRowIndent">18,0,0,0</Thickness> <!-- SpaceXl: a child row under its parent -->
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- CORNERS & BORDERS --> <!-- CORNERS & BORDERS -->
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary> /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent; event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
/// <summary>A Mission Control session called handoff_session: (paneId, briefPath). The UI replaces
/// the tile whose IdentityKey is paneId with a fresh session starting from the brief.</summary>
event Action<string, string>? SessionHandoffRequestedEvent;
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI /// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary> /// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent; event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
@@ -133,8 +137,12 @@ public interface IWorkerClient : INotifyPropertyChanged
/// in a task's worktree (same worktree prep as an autonomous run).</summary> /// in a task's worktree (same worktree prep as an autonomous run).</summary>
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default); Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory -- /// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree.</summary> /// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default); Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
/// <summary>Launch spec resuming a recorded Mission Control session in its cwd, as tile paneId.</summary>
Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default);
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given /// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list. /// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session /// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
+29 -2
View File
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string>? TaskQuestionAskedEvent; public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent; public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent; public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? SessionHandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent; public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent; public event Action<string, string>? HandlerUsageWarningEvent;
public event Action? ConnectionRestoredEvent; public event Action? ConnectionRestoredEvent;
@@ -160,6 +161,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase)); Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
}); });
_hub.On<string, string>("SessionHandoffRequested", (paneId, briefPath) =>
{
Dispatcher.UIThread.Post(() => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath));
});
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) => _hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
{ {
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds)); Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
@@ -298,6 +304,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
ok = true; ok = true;
return result; return result;
} }
catch (Exception ex) when (ex is not OperationCanceledException)
{
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
return default;
}
catch { return default; } catch { return default; }
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
} }
@@ -312,6 +323,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await invoke(); await invoke();
ok = true; ok = true;
} }
catch (Exception ex)
{
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
throw;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
} }
@@ -326,6 +342,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
ok = true; ok = true;
return result; return result;
} }
catch (Exception ex)
{
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
throw;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
} }
@@ -620,8 +641,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct)); => await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct)); => await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, paneId, ct));
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
public async Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetResumeSessionLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetResumeSessionLaunchSpec", sessionId, paneId, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct)); => await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
@@ -6,15 +6,18 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary> /// <summary>
/// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a /// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a
/// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it /// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it
/// toggles <see cref="IsCollapsed"/> instead of changing the selection. /// toggles <see cref="IsCollapsed"/> instead of changing the selection. Folders nest arbitrarily
/// deep via <see cref="ParentFolderId"/>; <see cref="Children"/> mixes lists and subfolders, the
/// same way <see cref="ListsIslandViewModel.TopLevel"/> mixes folders and folder-less lists.
/// </summary> /// </summary>
public sealed partial class ListFolderNavItemViewModel : ViewModelBase public sealed partial class ListFolderNavItemViewModel : ViewModelBase
{ {
public required string Id { get; init; } public required string Id { get; init; }
public string? ParentFolderId { get; set; }
[ObservableProperty] private string _name = ""; [ObservableProperty] private string _name = "";
[ObservableProperty] private string _iconKey = "Folder"; [ObservableProperty] private string _iconKey = "Folder";
[ObservableProperty] private bool _isCollapsed; [ObservableProperty] private bool _isCollapsed;
// Sum of the child lists' counts — a collapsed folder still shows what's inside it. // Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
[ObservableProperty] private int _count; [ObservableProperty] private int _count;
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder). // Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
[ObservableProperty] private bool _isDropTarget; [ObservableProperty] private bool _isDropTarget;
@@ -23,5 +26,5 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow; [ObservableProperty] private bool _dropHintBelow;
public ObservableCollection<ListNavItemViewModel> Lists { get; } = new(); public ObservableCollection<object> Children { get; } = new();
} }
@@ -215,10 +215,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary> /// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
public ObservableCollection<object> TopLevel { get; } = new(); public ObservableCollection<object> TopLevel { get; } = new();
/// <summary>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second /// <summary>Every sidebar folder, at any depth — a recursive projection of
/// collection to keep in sync.</summary> /// <see cref="TopLevel"/>, not a second collection to keep in sync.</summary>
public IReadOnlyList<ListFolderNavItemViewModel> Folders => public IReadOnlyList<ListFolderNavItemViewModel> Folders => AllFolders(TopLevel).ToList();
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
private static IEnumerable<ListFolderNavItemViewModel> AllFolders(IEnumerable<object> items)
{
foreach (var item in items)
{
if (item is not ListFolderNavItemViewModel folder) continue;
yield return folder;
foreach (var descendant in AllFolders(folder.Children))
yield return descendant;
}
}
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary> /// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
public IReadOnlyList<ListNavItemViewModel> RootLists => public IReadOnlyList<ListNavItemViewModel> RootLists =>
TopLevel.OfType<ListNavItemViewModel>().ToList(); TopLevel.OfType<ListNavItemViewModel>().ToList();
@@ -320,10 +330,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
// Folders and top-level lists share one sort_order space, so both go into a single // Every container (the top level, and each folder's children) gets its own ordered
// ordered bucket and get merged by it — that's what interleaves them. // bucket keyed by sort_order, since folders and lists at any depth share one sort_order
var topLevel = new List<(int Sort, object Item)>(); // space. Buckets are keyed by parent folder id; null (via topLevelBucket) is the top level.
var topLevelBucket = new List<(int Sort, object Item)>();
var childBuckets = new Dictionary<string, List<(int Sort, object Item)>>();
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>(); var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
List<(int Sort, object Item)> BucketFor(string? parentFolderId) =>
parentFolderId is { } pid && foldersById.ContainsKey(pid)
? childBuckets.TryGetValue(pid, out var b) ? b : childBuckets[pid] = new()
: topLevelBucket;
foreach (var f in await lists.GetFoldersAsync(ct)) foreach (var f in await lists.GetFoldersAsync(ct))
{ {
var folder = new ListFolderNavItemViewModel var folder = new ListFolderNavItemViewModel
@@ -332,9 +350,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = f.Name, Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon, IconKey = f.Icon ?? ListFolderIcon,
IsCollapsed = f.IsCollapsed, IsCollapsed = f.IsCollapsed,
ParentFolderId = f.ParentFolderId,
}; };
foldersById[f.Id] = folder; foldersById[f.Id] = folder;
topLevel.Add((f.SortOrder, folder)); // Parent always precedes its children in sort_order (PersistOrderAsync walks
// depth-first), so the parent bucket already exists by the time a child is seen.
BucketFor(f.ParentFolderId).Add((f.SortOrder, folder));
} }
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -357,16 +378,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner)) BucketFor(item.FolderId).Add((l.SortOrder, item));
owner.Lists.Add(item);
else
topLevel.Add((l.SortOrder, item));
} }
// OrderBy is stable, so legacy rows (folders and lists each numbered from 0 // OrderBy is stable, so legacy rows (folders and lists each numbered from 0
// independently) keep the old look — folders first — until the first drag. // independently) keep the old look — folders first — until the first drag.
foreach (var (_, item) in topLevel.OrderBy(t => t.Sort)) void Materialize(List<(int Sort, object Item)> bucket, System.Collections.IList target)
TopLevel.Add(item); {
foreach (var (_, item) in bucket.OrderBy(t => t.Sort))
{
target.Add(item);
if (item is ListFolderNavItemViewModel folder && childBuckets.TryGetValue(folder.Id, out var childBucket))
Materialize(childBucket, folder.Children);
}
}
Materialize(topLevelBucket, TopLevel);
ok = true; ok = true;
} }
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
@@ -517,10 +543,26 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (target is not null) target.IsDropTarget = true; if (target is not null) target.IsDropTarget = true;
} }
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or /// <summary>The collection a row is rendered in: its folder's <c>Children</c>, or
/// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary> /// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary>
public System.Collections.IList ContainerOf(ListNavItemViewModel row) => public System.Collections.IList ContainerOf(ListNavItemViewModel row) =>
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? (System.Collections.IList)TopLevel; Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Children ?? (System.Collections.IList)TopLevel;
/// <summary>The collection a folder is rendered in: its parent's <c>Children</c>, or
/// <see cref="TopLevel"/> when it has none.</summary>
public System.Collections.IList ContainerOf(ListFolderNavItemViewModel folder) =>
Folders.FirstOrDefault(f => f.Id == folder.ParentFolderId)?.Children ?? (System.Collections.IList)TopLevel;
/// <summary>True when <paramref name="candidateId"/> is <paramref name="folder"/> itself or
/// nested anywhere under it — the guard against dropping a folder into its own subtree.</summary>
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, string? candidateId)
{
if (candidateId is null) return false;
if (candidateId == folder.Id) return true;
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
if (IsSelfOrDescendant(child, candidateId)) return true;
return false;
}
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary> /// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) => public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
@@ -528,10 +570,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// <summary> /// <summary>
/// Drops <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'s /// Drops <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'s
/// position. Since a drop position implies a container, dropping a list onto a row in a /// position. Since a drop position implies a container, dropping a row onto a row in a
/// different folder (or next to anything at the top level) also *moves* it there — that's the /// different folder (or next to anything at the top level) also *moves* it there — that's the
/// only way to drag a list out of a folder. Folders live at the top level only, so a folder /// only way to drag something out of a folder. A folder dropped next to a target inside its own
/// dropped onto a row inside a folder is ignored rather than nested. /// subtree is ignored rather than nested into itself.
/// </summary> /// </summary>
public async Task DropAsync(object source, object target, bool placeBelow) public async Task DropAsync(object source, object target, bool placeBelow)
{ {
@@ -540,17 +582,43 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (source is ListFolderNavItemViewModel folder) if (source is ListFolderNavItemViewModel folder)
{ {
if (target is ListNavItemViewModel { FolderId: not null }) return; var newParentId = target switch
MoveWithinCollection(TopLevel, folder, target, placeBelow); {
await PersistOrderAsync(null); ListFolderNavItemViewModel tf => tf.ParentFolderId,
ListNavItemViewModel tl => tl.FolderId,
_ => null,
};
if (IsSelfOrDescendant(folder, newParentId)) return;
var targetContainer = target switch
{
ListFolderNavItemViewModel tf => ContainerOf(tf),
ListNavItemViewModel tl => ContainerOf(tl),
_ => (System.Collections.IList)TopLevel,
};
var parentChanged = folder.ParentFolderId != newParentId;
if (parentChanged)
{
ContainerOf(folder).Remove(folder);
folder.ParentFolderId = newParentId;
targetContainer.Add(folder);
}
MoveWithinCollection(targetContainer, folder, target, placeBelow);
await PersistOrderAsync(folder);
return; return;
} }
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return; if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
// Next to a folder *header* means top level, not inside it — MoveToFolderAsync is the // Next to a folder *header* means that folder's own container, not inside it —
// drop-into-the-folder path. // MoveToFolderAsync is the drop-into-the-folder path.
var targetFolderId = target is ListNavItemViewModel t ? t.FolderId : null; var targetFolderId = target switch
{
ListNavItemViewModel t => t.FolderId,
ListFolderNavItemViewModel tf => tf.ParentFolderId,
_ => null,
};
var folderChanged = row.FolderId != targetFolderId; var folderChanged = row.FolderId != targetFolderId;
if (folderChanged) if (folderChanged)
{ {
@@ -563,40 +631,58 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await PersistOrderAsync(folderChanged ? row : null); await PersistOrderAsync(folderChanged ? row : null);
} }
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is /// <summary>Appends a row (list or folder) to <paramref name="folder"/> (or to the top level
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary> /// when null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty
public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder) /// folders. A folder source dropped into itself or one of its own subfolders is ignored.</summary>
public async Task MoveToFolderAsync(object source, ListFolderNavItemViewModel? folder)
{ {
if (source.Kind != ListKind.User) return; if (source is ListFolderNavItemViewModel sourceFolder)
if (source.FolderId == folder?.Id) return; {
if (ReferenceEquals(sourceFolder, folder)) return;
if (IsSelfOrDescendant(sourceFolder, folder?.Id)) return;
if (sourceFolder.ParentFolderId == folder?.Id) return;
ContainerOf(source).Remove(source); ContainerOf(sourceFolder).Remove(sourceFolder);
source.FolderId = folder?.Id; sourceFolder.ParentFolderId = folder?.Id;
ContainerOf(source).Add(source); ContainerOf(sourceFolder).Add(sourceFolder);
await PersistOrderAsync(source); await PersistOrderAsync(sourceFolder);
return;
}
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
if (row.FolderId == folder?.Id) return;
ContainerOf(row).Remove(row);
row.FolderId = folder?.Id;
ContainerOf(row).Add(row);
await PersistOrderAsync(row);
} }
/// <summary> /// <summary>
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence /// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence,
/// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new /// walked depth-first (a folder, then its children, at any depth) and, when a row changed
/// <c>folder_id</c>. /// container, its new <c>folder_id</c>/<c>parent_folder_id</c>.
/// </summary> /// </summary>
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow) private async Task PersistOrderAsync(object? movedItem)
{ {
var ordered = new List<(string Id, bool IsFolder)>(); var ordered = new List<(string Id, bool IsFolder)>();
foreach (var item in TopLevel) void Walk(IEnumerable<object> items)
{ {
switch (item) foreach (var item in items)
{ {
case ListFolderNavItemViewModel f: switch (item)
ordered.Add((f.Id, true)); {
ordered.AddRange(f.Lists.Select(l => (RawId(l.Id), false))); case ListFolderNavItemViewModel f:
break; ordered.Add((f.Id, true));
case ListNavItemViewModel l: Walk(f.Children);
ordered.Add((RawId(l.Id), false)); break;
break; case ListNavItemViewModel l:
ordered.Add((RawId(l.Id), false));
break;
}
} }
} }
Walk(TopLevel);
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false; var ok = false;
@@ -604,8 +690,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
if (movedRow is not null) switch (movedItem)
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId); {
case ListNavItemViewModel movedRow:
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
break;
case ListFolderNavItemViewModel movedFolder:
await lists.SetFolderParentAsync(movedFolder.Id, movedFolder.ParentFolderId);
break;
}
await lists.ReorderSidebarAsync(ordered); await lists.ReorderSidebarAsync(ordered);
ok = true; ok = true;
} }
@@ -671,7 +764,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
private void RecomputeFolderCounts() private void RecomputeFolderCounts()
{ {
foreach (var f in Folders) f.Count = f.Lists.Sum(l => l.Count); foreach (var item in TopLevel)
if (item is ListFolderNavItemViewModel f) ComputeFolderCount(f);
}
private static int ComputeFolderCount(ListFolderNavItemViewModel folder)
{
var sum = 0;
foreach (var child in folder.Children)
sum += child switch
{
ListNavItemViewModel l => l.Count,
ListFolderNavItemViewModel f => ComputeFolderCount(f),
_ => 0,
};
folder.Count = sum;
return sum;
} }
private static string RawId(string rowId) => private static string RawId(string rowId) =>
@@ -805,8 +913,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
.Select(l => l.Id) .Select(l => l.Id)
.ToList() .ToList()
.IndexOf(entity.Id); .IndexOf(entity.Id);
folder.Lists.Insert( folder.Children.Insert(
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx, containerIdx < 0 || containerIdx > folder.Children.Count ? folder.Children.Count : containerIdx,
item); item);
} }
else TopLevel.Add(item); else TopLevel.Add(item);
@@ -208,6 +208,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void FlashFooter(string message, WorkerLogLevel level) private void FlashFooter(string message, WorkerLogLevel level)
{ {
if (level == WorkerLogLevel.Error)
Serilog.Log.Warning("Footer error: {message}", message);
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}"; WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
WorkerLogLevel = level; WorkerLogLevel = level;
IsWorkerLogVisible = true; IsWorkerLogVisible = true;
@@ -19,6 +19,25 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task). // Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null; public bool IsTaskBased => TaskId is not null;
/// <summary>Stable key behind <see cref="IdentityColorKey"/>. Defaults to the task id, so a
/// handoff — same task, new pane — keeps its colour for free; an ad-hoc pane gets a fresh one.
/// Settable so a handoff that mints a new key can inherit the outgoing pane's.</summary>
public string IdentityKey { get; init; }
/// <summary>Resource key of this tile's identity colour (via DotBrushConverter). Derived from
/// <see cref="IdentityKey"/>, never stored — the colour survives restarts without persistence.</summary>
public string IdentityColorKey => $"Identity{StableIndex(IdentityKey, 6) + 1}";
// FNV-1a, not string.GetHashCode: the latter is randomized per process, so the same task would
// change colour on every app start.
private static int StableIndex(string value, int buckets)
{
uint hash = 2166136261;
foreach (var c in value)
hash = (hash ^ c) * 16777619;
return (int)(hash % (uint)buckets);
}
/// <summary>Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge /// <summary>Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge
/// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not /// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not
/// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the /// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the
@@ -60,7 +79,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
public event Action<string, int>? SessionDiedAfterStartup; public event Action<string, int>? SessionDiedAfterStartup;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane /// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites. /// (no task, never deduped) — its host sets <see cref="IdentityKey"/>, the tile's paneId.
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>) /// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
/// so the tile — and its starting spinner — is visible while the launch spec is still being /// so the tile — and its starting spinner — is visible while the launch spec is still being
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary> /// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
@@ -70,6 +89,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory) Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
{ {
TaskId = taskId; TaskId = taskId;
IdentityKey = taskId ?? Guid.NewGuid().ToString("n");
_displayTitle = displayTitle; _displayTitle = displayTitle;
_descriptorFactory = descriptorFactory; _descriptorFactory = descriptorFactory;
Subscribe(Terminal); Subscribe(Terminal);
@@ -112,11 +132,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary> /// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
public void Start() => _ = StartAsync(); public void Start() => _ = StartAsync();
/// <summary>Working directory of the launched session, known once the launch spec resolved —
/// a handoff reopens the fresh session there.</summary>
public string? Cwd { get; private set; }
private async System.Threading.Tasks.Task StartAsync() private async System.Threading.Tasks.Task StartAsync()
{ {
try try
{ {
Terminal.Start(await _descriptorFactory()); var descriptor = await _descriptorFactory();
Cwd = descriptor.Cwd;
Terminal.Start(descriptor);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -124,12 +150,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
} }
} }
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
public static ConPtyPaneViewModel CreateAdHoc(
string displayTitle,
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
=> new(null, displayTitle, descriptorFactory);
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error) if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
@@ -5,6 +5,8 @@ using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.MissionControl; using ClaudeDo.Ui.ViewModels.MissionControl;
@@ -75,6 +77,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// fresh session is two clicks instead of navigating a folder picker. // fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new(); public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
/// <summary>"Recent sessions" flyout rows: each chain's origin session, its handoffs right below it.</summary>
public ObservableCollection<RecentSessionViewModel> RecentSessions { get; } = new();
/// <summary>False shows the flyout's empty-state line.</summary>
public bool HasRecentSessions => RecentSessions.Count > 0;
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see // View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked). // MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; } public Action? BrowseForSessionRequested { get; set; }
@@ -108,6 +116,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
_worker.HandoffRequestedEvent += _onHandoffRequested; _worker.HandoffRequestedEvent += _onHandoffRequested;
_worker.SessionHandoffRequestedEvent += OpenSessionHandoff;
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); }; _onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested; _worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
@@ -214,6 +223,84 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
} }
/// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on
/// every flyout open.</summary>
internal async System.Threading.Tasks.Task RefreshRecentSessionsAsync()
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var sessions = await new MissionControlSessionRepository(ctx).ListRecentAsync(DateTime.UtcNow.AddDays(-14), 50);
var ids = sessions.Select(s => s.SessionId).ToList();
// A task's current session resumes through its task tile, so it stays deduped by TaskId.
var taskCurrent = (await ctx.Tasks.AsNoTracking()
.Where(t => t.InteractiveSessionId != null && ids.Contains(t.InteractiveSessionId))
.Select(t => t.InteractiveSessionId!)
.ToListAsync()).ToHashSet();
RecentSessions.Clear();
foreach (var (s, isHandoff) in NestHandoffs(sessions))
{
var time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g");
var briefPath = s.BriefPath;
RecentSessions.Add(new RecentSessionViewModel
{
Title = s.Title,
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
IsHandoff = isHandoff,
ResumeCommand = new RelayCommand(() => ResumeSession(s, taskCurrent.Contains(s.SessionId))),
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
OnPropertyChanged(nameof(HasRecentSessions));
}
/// <summary>Orders newest-first sessions into chains: each origin session (no recorded predecessor
/// in the set) followed by every session handed off from it, directly or indirectly, oldest
/// first. Chains are ordered by their newest member.</summary>
internal static IEnumerable<(MissionControlSessionEntity Session, bool IsHandoff)> NestHandoffs(
IReadOnlyList<MissionControlSessionEntity> newestFirst)
{
var byId = newestFirst.ToDictionary(s => s.SessionId);
string RootOf(MissionControlSessionEntity s)
{
var seen = new HashSet<string>();
while (s.PredecessorSessionId is { } p && byId.TryGetValue(p, out var prev) && seen.Add(s.SessionId))
s = prev;
return s.SessionId;
}
return newestFirst
.GroupBy(RootOf)
.SelectMany(chain => chain
.OrderBy(s => s.SessionId != chain.Key)
.ThenBy(s => s.StartedAt)
.Select(s => (s, s.SessionId != chain.Key)));
}
// A task's current session reopens its task tile (deduped, same worktree guards); any other
// session gets a fresh ad-hoc tile resuming it in its recorded cwd.
private void ResumeSession(MissionControlSessionEntity session, bool isTaskCurrent)
{
if (session.TaskId is { } taskId && isTaskCurrent)
{
_ = OpenConPtySessionAsync(taskId);
return;
}
var paneId = Guid.NewGuid().ToString("n");
var sessionId = session.SessionId;
AddConPtyPane(new ConPtyPaneViewModel(null, session.Title,
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
}
private void ShowBrief(string briefPath)
{
var (ok, error) = ShellOpen.Path(briefPath);
if (!ok) ErrorReported?.Invoke(Loc.T("missionControl.briefOpenFailed", error ?? briefPath));
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes // Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the // through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here // manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -322,11 +409,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
var title = Path.GetFileName(directory.TrimEnd('\\', '/')); var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
if (string.IsNullOrEmpty(title)) title = directory; if (string.IsNullOrEmpty(title)) title = directory;
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title, // The key is minted here, not in the pane, because the launch spec needs it as the paneId.
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory)))); var paneId = Guid.NewGuid().ToString("n");
AddConPtyPane(new ConPtyPaneViewModel(null, title,
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory, paneId))) { IdentityKey = paneId });
return System.Threading.Tasks.Task.CompletedTask; return System.Threading.Tasks.Task.CompletedTask;
} }
/// <summary>A session called handoff_session: replaces its tile (same slot, same IdentityKey, so
/// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile
/// (closed meanwhile, or another app instance) = nothing to replace.</summary>
internal void OpenSessionHandoff(string paneId, string briefPath)
{
if (ConPtySessions.FirstOrDefault(s => s.IdentityKey == paneId) is not { Cwd: { } cwd } outgoing) return;
OpenRequested?.Invoke();
var slot = ConPtySessions.IndexOf(outgoing);
var taskId = outgoing.TaskId;
var title = outgoing.DisplayTitle;
CloseConPtySession(outgoing);
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId)))
{ IdentityKey = paneId }, slot);
}
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based: // List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by // creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself // TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
@@ -408,6 +515,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return; if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
OpenRequested?.Invoke(); OpenRequested?.Invoke();
// Keep the tile where it was: the incoming phase re-enters at the outgoing pane's slot
// instead of being appended, so a handoff doesn't reshuffle every other tile in the grid.
var slot = ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } first
? ConPtySessions.IndexOf(first)
: (int?)null;
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList()) foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
CloseConPtySession(stale); CloseConPtySession(stale);
@@ -428,7 +540,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))) () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
{ IsListHandler = true }); { IsListHandler = true }, slot);
} }
// The 5h usage window is about to run out under this handler session: type the worker's warning // The 5h usage window is about to run out under this handler session: type the worker's warning
@@ -446,14 +558,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec, // Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
// so the tile is on screen (spinner running) while the worker is still preparing the worktree. // so the tile is on screen (spinner running) while the worker is still preparing the worktree.
private void AddConPtyPane(ConPtyPaneViewModel pane) private void AddConPtyPane(ConPtyPaneViewModel pane, int? index = null)
{ {
pane.ErrorReported += OnConPtyPaneError; pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += OnPaneCloseRequested; pane.CloseRequested += OnPaneCloseRequested;
pane.SubmitForReviewRequested += OnPaneSubmitForReview; pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied; pane.SessionDiedAfterStartup += OnPaneSessionDied;
pane.AbandonRunRequested += OnPaneAbandonRun; pane.AbandonRunRequested += OnPaneAbandonRun;
ConPtySessions.Add(pane); ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane);
pane.Start(); pane.Start();
} }
@@ -610,6 +722,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored; _worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.HandoffRequestedEvent -= _onHandoffRequested; _worker.HandoffRequestedEvent -= _onHandoffRequested;
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested; _worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning; _worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
@@ -647,6 +760,20 @@ public sealed class QueuedTaskViewModel
public IRelayCommand? OpenSessionCommand { get; init; } public IRelayCommand? OpenSessionCommand { get; init; }
} }
/// <summary>One row in the "Recent sessions" flyout.</summary>
public sealed class RecentSessionViewModel
{
public required string Title { get; init; }
/// <summary>Start time plus cwd (origin) or the handoff label (handoff).</summary>
public required string Meta { get; init; }
/// <summary>Indents the row under its chain's origin session.</summary>
public bool IsHandoff { get; init; }
public required IRelayCommand ResumeCommand { get; init; }
/// <summary>Opens the session's brief.md; null for a session that did not start from one.</summary>
public IRelayCommand? ShowBriefCommand { get; init; }
public bool HasBrief => ShowBriefCommand is not null;
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary> /// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel public sealed class SessionTargetViewModel
{ {
@@ -139,7 +139,7 @@
</Grid> </Grid>
</Border> </Border>
<ItemsControl ItemsSource="{Binding Lists}" <ItemsControl ItemsSource="{Binding Children}"
IsVisible="{Binding !IsCollapsed}" IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"/> Margin="14,0,0,0"/>
</StackPanel> </StackPanel>
@@ -142,18 +142,21 @@ public partial class ListsIslandView : UserControl
private enum FolderDrop { None, Into, Beside } private enum FolderDrop { None, Into, Beside }
/// <summary> /// <summary>
/// What a drop on a folder header means. For a list the middle half moves it *into* the folder /// What a drop on a folder header means: the middle half moves the source *into* the folder
/// (the only route into a collapsed or empty one) while the outer quarters insert next to the /// (the only route into a collapsed or empty one) while the outer quarters insert next to the
/// folder at the top level; a folder source has no "into" — folders never nest — so it splits /// folder in its own container. A folder dropped onto itself or one of its own subfolders has
/// in half. /// no valid "into" or "beside" and is rejected.
/// </summary> /// </summary>
private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop( private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop(
object source, ListFolderNavItemViewModel folder, double y, double height) object source, ListFolderNavItemViewModel folder, double y, double height)
{ {
if (source is ListFolderNavItemViewModel dragged) if (source is ListFolderNavItemViewModel dragged)
return ReferenceEquals(dragged, folder) {
? (FolderDrop.None, false) if (IsSelfOrDescendant(dragged, folder)) return (FolderDrop.None, false);
: (FolderDrop.Beside, y > height / 2); if (y < height * 0.25) return (FolderDrop.Beside, false);
if (y > height * 0.75) return (FolderDrop.Beside, true);
return dragged.ParentFolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
}
if (source is not ListNavItemViewModel row) return (FolderDrop.None, false); if (source is not ListNavItemViewModel row) return (FolderDrop.None, false);
if (y < height * 0.25) return (FolderDrop.Beside, false); if (y < height * 0.25) return (FolderDrop.Beside, false);
@@ -161,6 +164,14 @@ public partial class ListsIslandView : UserControl
return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false); return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
} }
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, ListFolderNavItemViewModel candidate)
{
if (ReferenceEquals(folder, candidate)) return true;
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
if (IsSelfOrDescendant(child, candidate)) return true;
return false;
}
private void OnFolderDragOver(object? sender, DragEventArgs e) private void OnFolderDragOver(object? sender, DragEventArgs e)
{ {
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
@@ -201,8 +212,8 @@ public partial class ListsIslandView : UserControl
var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height); var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height);
vm.ClearDropHints(); vm.ClearDropHints();
if (kind == FolderDrop.Into && source is ListNavItemViewModel row) if (kind == FolderDrop.Into)
await vm.MoveToFolderAsync(row, folder); await vm.MoveToFolderAsync(source, folder);
else if (kind == FolderDrop.Beside) else if (kind == FolderDrop.Beside)
await vm.DropAsync(source, folder, placeBelow); await vm.DropAsync(source, folder, placeBelow);
} }
@@ -224,8 +235,6 @@ public partial class ListsIslandView : UserControl
var source = DragSource(vm, e); var source = DragSource(vm, e);
if (source is null || ReferenceEquals(source, target)) { Reject(vm, e); return; } if (source is null || ReferenceEquals(source, target)) { Reject(vm, e); return; }
// A folder can only be dropped at the top level, so a row inside a folder is no target.
if (source is ListFolderNavItemViewModel && target.FolderId is not null) { Reject(vm, e); return; }
// Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line. // Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line.
object hintTarget = target; object hintTarget = target;
@@ -251,7 +260,6 @@ public partial class ListsIslandView : UserControl
var source = DragSource(vm, e); var source = DragSource(vm, e);
if (source is null || ReferenceEquals(source, target)) return; if (source is null || ReferenceEquals(source, target)) return;
if (source is ListFolderNavItemViewModel && target.FolderId is not null) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2; var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
vm.ClearDropHints(); vm.ClearDropHints();
@@ -4,11 +4,16 @@
xmlns:views="using:ClaudeDo.Ui.Views" xmlns:views="using:ClaudeDo.Ui.Views"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:conv="using:Avalonia.Data.Converters" xmlns:conv="using:Avalonia.Data.Converters"
xmlns:cconv="using:ClaudeDo.Ui.Converters"
x:DataType="vm:ConPtyPaneViewModel" x:DataType="vm:ConPtyPaneViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView"> x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True"> <Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<!-- Identity strip: same colour for the same session across handoffs and restarts. -->
<Border DockPanel.Dock="Left" Width="3"
Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" />
<!-- Header: title + close --> <!-- Header: title + close -->
<Border DockPanel.Dock="Top" <Border DockPanel.Dock="Top"
Background="{DynamicResource Surface2Brush}" Background="{DynamicResource Surface2Brush}"
@@ -48,6 +48,60 @@
</MenuFlyout> </MenuFlyout>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
<Button x:Name="RecentSessionsButton" Classes="icon-btn"
ToolTip.Tip="{loc:Tr missionControl.recentSessions}">
<PathIcon Data="{StaticResource Icon.Clock}" Width="15" Height="15"/>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard" Opening="OnRecentSessionsFlyoutOpening">
<StackPanel Width="380" Spacing="8" Margin="4">
<TextBlock Classes="eyebrow"
Text="{loc:Tr missionControl.recentSessions}"
Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.recentSessionsEmpty}"
IsVisible="{Binding !HasRecentSessions}"
Foreground="{DynamicResource TextMuteBrush}" />
<ScrollViewer MaxHeight="420">
<ItemsControl x:Name="RecentSessionsList" ItemsSource="{Binding RecentSessions}">
<ItemsControl.Styles>
<Style Selector="Grid.handoff">
<Setter Property="Margin" Value="{StaticResource NestedRowIndent}" />
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:RecentSessionViewModel">
<Grid ColumnDefinitions="*,Auto" Classes.handoff="{Binding IsHandoff}">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,4" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta"
Text="{Binding Meta}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Meta}"
Foreground="{DynamicResource TextMuteBrush}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Center">
<Button Classes="btn"
Content="{loc:Tr missionControl.showBrief}"
Command="{Binding ShowBriefCommand}"
IsVisible="{Binding HasBrief}" />
<Button Classes="btn"
Content="{loc:Tr missionControl.resumeSession}"
Command="{Binding ResumeCommand}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}" Command="{Binding ToggleLayoutCommand}"
ToolTip.Tip="{Binding LayoutToggleLabel}"> ToolTip.Tip="{Binding LayoutToggleLabel}">
@@ -84,7 +138,11 @@
IsVisible="{Binding IsFocusMode}"> IsVisible="{Binding IsFocusMode}">
<TabControl.ItemTemplate> <TabControl.ItemTemplate>
<DataTemplate x:DataType="vmm:ConPtyPaneViewModel"> <DataTemplate x:DataType="vmm:ConPtyPaneViewModel">
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" /> <StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
Fill="{Binding IdentityColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
</StackPanel>
</DataTemplate> </DataTemplate>
</TabControl.ItemTemplate> </TabControl.ItemTemplate>
</TabControl> </TabControl>
@@ -29,10 +29,14 @@ public partial class MissionControlView : UserControl
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged; DataContextChanged += OnDataContextChanged;
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width; QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
// Resume/Brief buttons don't close a plain Flyout on their own.
RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => RecentSessionsButton.Flyout?.Hide());
} }
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync(); private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path. // Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void BrowseForSessionAsync() private async void BrowseForSessionAsync()
{ {
+90
View File
@@ -0,0 +1,90 @@
using System.ComponentModel;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
/// <summary>Confirmation of a handoff_session call: where the brief was written.</summary>
public sealed record SessionHandoffResult(bool Requested, string BriefPath);
/// <summary>Generic Mission Control handoff. Reached only through the per-tile <c>claudedo_pane</c>
/// MCP entry, whose headers identify the tile and session — the model never passes an id.</summary>
[McpServerToolType]
public sealed class SessionHandoffMcpTools
{
/// <summary>Header naming the calling tile (ConPtyPaneViewModel.IdentityKey).</summary>
public const string PaneHeader = "X-ClaudeDo-Pane";
/// <summary>Header naming the calling session's claude session id.</summary>
public const string SessionHeader = "X-ClaudeDo-Session";
/// <summary>The only tool the <c>claudedo_pane</c> entry lists, and the one the plain entry hides.</summary>
public const string ToolName = "handoff_session";
private readonly HubBroadcaster _broadcaster;
private readonly IHttpContextAccessor _http;
public SessionHandoffMcpTools(HubBroadcaster broadcaster, IHttpContextAccessor http)
{
_broadcaster = broadcaster;
_http = http;
}
/// <summary>Writes the brief under the tile's session dir and asks the UI to replace the tile.</summary>
[McpServerTool(Name = ToolName), Description(
"Hand this session off to a fresh session in the same Mission Control tile and directory, when the " +
"user asks for it (e.g. the context is full). The brief is the ONLY context the fresh session gets -- " +
"make it self-contained. This tile closes right after the call; end your turn immediately.")]
public async Task<SessionHandoffResult> HandoffSession(
[Description("Markdown brief for the fresh session: goal, done, decisions, current state, next steps, open questions, file paths.")] string brief,
CancellationToken cancellationToken = default)
{
var headers = _http.HttpContext?.Request.Headers;
var paneId = headers?[PaneHeader].ToString();
if (string.IsNullOrEmpty(paneId))
throw new InvalidOperationException("handoff_session only works from a ClaudeDo Mission Control tile.");
if (string.IsNullOrWhiteSpace(brief))
throw new InvalidOperationException("brief must not be empty.");
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(dir);
var briefPath = Path.Combine(dir, "brief.md");
var fromSession = headers![SessionHeader].ToString();
var header = fromSession.Length == 0 ? "" : $"{HandedOffFromPrefix}{fromSession}`.\n\n";
await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken);
await _broadcaster.SessionHandoffRequested(paneId, briefPath);
return new SessionHandoffResult(true, briefPath);
}
/// <summary>Start of a brief's first line; the handing-off session id and a closing backtick follow.</summary>
private const string HandedOffFromPrefix = "Handed off from session `";
/// <summary>The session id a brief was handed off from, or null when its first line carries none.</summary>
public static string? ReadHandedOffFrom(string briefPath)
{
var first = File.ReadLines(briefPath).FirstOrDefault();
if (first is null || !first.StartsWith(HandedOffFromPrefix, StringComparison.Ordinal)) return null;
var end = first.IndexOf('`', HandedOffFromPrefix.Length);
return end > HandedOffFromPrefix.Length ? first[HandedOffFromPrefix.Length..end] : null;
}
/// <summary>List-tools filter: a request carrying <see cref="PaneHeader"/> (the per-tile entry) sees
/// only handoff_session; every other request sees everything but it.</summary>
public static McpRequestHandler<ListToolsRequestParams, ListToolsResult> FilterByPane(
McpRequestHandler<ListToolsRequestParams, ListToolsResult> next) =>
async (request, cancellationToken) =>
{
var result = await next(request, cancellationToken);
var http = request.Services?.GetService<IHttpContextAccessor>()?.HttpContext;
result.Tools = result.Tools.Where(t => IsVisible(t.Name, http)).ToList();
return result;
};
/// <summary>Visibility rule behind <see cref="FilterByPane"/>.</summary>
internal static bool IsVisible(string toolName, HttpContext? http) =>
(toolName == ToolName) == !string.IsNullOrEmpty(http?.Request.Headers[PaneHeader].ToString());
}
@@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) => public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase); _hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
/// <summary>A Mission Control session called handoff_session -- the UI replaces the tile whose
/// IdentityKey is paneId with a fresh session that starts from the brief.</summary>
public Task SessionHandoffRequested(string paneId, string briefPath) =>
_hub.Clients.All.SendAsync("SessionHandoffRequested", paneId, briefPath);
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage // start_list_handler (External MCP) -- the App creates the handler task and opens the triage
// tile, exactly like the "Let Claude handle it" button. // tile, exactly like the "Let Claude handle it" button.
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Hub;
/// <summary>
/// Logs a Warning for any exception thrown out of a hub method body, so a failed UI action
/// leaves a trace in <c>worker-.log</c> instead of only a generic error at the caller.
/// Does NOT see an argument-binding failure (e.g. a stray CancellationToken parameter) — SignalR
/// rejects those while parsing the invocation message, before a hub method is ever dispatched.
/// </summary>
public sealed class HubExceptionLoggingFilter(ILogger<HubExceptionLoggingFilter> logger) : IHubFilter
{
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object?>> next)
{
try
{
return await next(invocationContext);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Hub method {method_name} failed", invocationContext.HubMethodName);
throw;
}
}
}
+26 -5
View File
@@ -277,8 +277,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// TryAdvanceParentAsync side effect — a direct-repo delete from the details pane // TryAdvanceParentAsync side effect — a direct-repo delete from the details pane
// used to skip it, permanently wedging a WaitingForChildren parent whose last // used to skip it, permanently wedging a WaitingForChildren parent whose last
// child was deleted from there. // child was deleted from there.
public async Task DeleteTask(string taskId, CancellationToken cancellationToken) // No CancellationToken parameter: SignalR can't bind one on a non-streaming method and fails
// every invocation with a generic server error before the body runs.
public async Task DeleteTask(string taskId)
{ {
var cancellationToken = CancellationToken.None;
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var repo = new TaskRepository(ctx); var repo = new TaskRepository(ctx);
var task = await repo.GetByIdAsync(taskId, cancellationToken) var task = await repo.GetByIdAsync(taskId, cancellationToken)
@@ -776,16 +779,34 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
if (_interactiveLaunchSpec is null) if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured."); throw new InvalidOperationException("Interactive launch spec service is not configured.");
_broadcaster.WorktreeUpdated(taskId); _broadcaster.WorktreeUpdated(taskId);
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted); // A task tile's paneId is its task id (ConPtyPaneViewModel.IdentityKey).
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted, paneId: taskId);
}); });
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory -- // Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
// no task, no worktree, no session-skills seeding. // no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() => // into the handoff hint.
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() =>
{ {
if (_interactiveLaunchSpec is null) if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured."); throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted); return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
});
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
});
/// <summary>Launch spec resuming a recorded Mission Control session ("Recent sessions") in a new tile.</summary>
public Task<LaunchSpec> GetResumeSessionLaunchSpec(string sessionId, string paneId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildResumeAsync(sessionId, paneId, Context.ConnectionAborted);
}); });
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given // Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
+17 -2
View File
@@ -28,6 +28,7 @@ using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Usage.TokenTracker; using ClaudeDo.Worker.Usage.TokenTracker;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
using ClaudeDo.Worker.Worktrees; using ClaudeDo.Worker.Worktrees;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Serilog; using Serilog;
@@ -58,6 +59,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information() .MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
// SignalR logs an argument-binding failure (e.g. a stray CancellationToken parameter, see
// fix 352aaa52) at Debug on this specific source context — HubExceptionLoggingFilter can't
// see it either, since SignalR rejects the invocation while parsing the message, before any
// hub method (and its filter pipeline) is dispatched.
.MinimumLevel.Override("Microsoft.AspNetCore.SignalR.Internal.DefaultHubDispatcher", Serilog.Events.LogEventLevel.Debug)
.WriteTo.File( .WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"), System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day, rollingInterval: RollingInterval.Day,
@@ -90,7 +96,11 @@ builder.Services.AddHostedService<UntrackedWorktreeRecovery>();
// Default MaximumParallelInvocationsPerClient=1 serializes every hub call on one connection — // Default MaximumParallelInvocationsPerClient=1 serializes every hub call on one connection —
// a slow one (e.g. GetTicketProjects hitting an unreachable ticket system) blocks unrelated // a slow one (e.g. GetTicketProjects hitting an unreachable ticket system) blocks unrelated
// calls like UpdateList/UpdateListConfig behind it for the same 10s. // calls like UpdateList/UpdateListConfig behind it for the same 10s.
builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClient = 10) builder.Services.AddSignalR(options =>
{
options.MaximumParallelInvocationsPerClient = 10;
options.AddFilter<HubExceptionLoggingFilter>();
})
.AddJsonProtocol(options => .AddJsonProtocol(options =>
{ {
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
@@ -375,6 +385,8 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<AgentMcpTools>(); externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>(); externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<HandoffMcpTools>(); externalBuilder.Services.AddScoped<HandoffMcpTools>();
externalBuilder.Services.AddScoped<SessionHandoffMcpTools>();
externalBuilder.Services.AddHttpContextAccessor();
externalBuilder.Services.AddScoped<ListHandlerMcpTools>(); externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>(); externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
externalBuilder.Services.AddScoped<TaskWaitMcpTools>(); externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
@@ -385,7 +397,9 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<NotesMcpTools>(); externalBuilder.Services.AddScoped<NotesMcpTools>();
externalBuilder.Services.AddMcpServer() externalBuilder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true) .WithHttpTransport(o => o.Stateless = true)
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)) .WithRequestFilters(f => f
.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)
.AddListToolsFilter(SessionHandoffMcpTools.FilterByPane))
.WithTools<ExternalMcpService>() .WithTools<ExternalMcpService>()
.WithTools<MergeMcpTools>() .WithTools<MergeMcpTools>()
.WithTools<WorktreeMcpTools>() .WithTools<WorktreeMcpTools>()
@@ -396,6 +410,7 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<AgentMcpTools>() .WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>() .WithTools<LifecycleMcpTools>()
.WithTools<HandoffMcpTools>() .WithTools<HandoffMcpTools>()
.WithTools<SessionHandoffMcpTools>()
.WithTools<ListHandlerMcpTools>() .WithTools<ListHandlerMcpTools>()
.WithTools<AppSettingsMcpTools>() .WithTools<AppSettingsMcpTools>()
.WithTools<TaskWaitMcpTools>() .WithTools<TaskWaitMcpTools>()
@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Text.Json;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Git; using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
@@ -43,6 +44,7 @@ public sealed class InteractiveLaunchSpecService
private readonly WorktreeManager _wtManager; private readonly WorktreeManager _wtManager;
private readonly GitService _git; private readonly GitService _git;
private readonly string _claudePath; private readonly string _claudePath;
private readonly WorkerConfig _cfg;
private readonly HandoffRoundTracker _handoffRounds; private readonly HandoffRoundTracker _handoffRounds;
private readonly HandlerRoundState _roundState; private readonly HandlerRoundState _roundState;
@@ -67,6 +69,7 @@ public sealed class InteractiveLaunchSpecService
_wtManager = wtManager; _wtManager = wtManager;
_git = git; _git = git;
_claudePath = cfg.ClaudeBin; _claudePath = cfg.ClaudeBin;
_cfg = cfg;
_handoffRounds = handoffRounds; _handoffRounds = handoffRounds;
_roundState = roundState; _roundState = roundState;
} }
@@ -81,8 +84,10 @@ public sealed class InteractiveLaunchSpecService
/// it has one, else the latest autonomous run's session; a task that has never run either /// it has one, else the latest autonomous run's session; a task that has never run either
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre- /// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before /// assigned a new session id via --session-id and persisted to InteractiveSessionId before
/// launch, so a closed/aborted session can be resumed next time.</summary> /// launch, so a closed/aborted session can be resumed next time. A non-null
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct) /// <paramref name="paneId"/> appends the Mission Control handoff hint (see
/// <see cref="PaneSessionArgsAsync"/>).</summary>
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct, string? paneId = null)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -184,17 +189,28 @@ public sealed class InteractiveLaunchSpecService
// persisted HERE, before launch, so a closed/aborted session -- even one that never got // persisted HERE, before launch, so a closed/aborted session -- even one that never got
// past startup -- still leaves an id the next open can resume. // past startup -- still leaves an id the next open can resume.
IReadOnlyList<string> args; IReadOnlyList<string> args;
string sessionId;
if (resumeSessionId is { Length: > 0 }) if (resumeSessionId is { Length: > 0 })
{ {
sessionId = resumeSessionId;
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort); args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
} }
else else
{ {
var sessionId = Guid.NewGuid().ToString(); sessionId = Guid.NewGuid().ToString();
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct); await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct); args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
} }
if (paneId is not null)
{
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = sessionDir, TaskId = taskId, Title = task.Title,
}, ct);
}
// Same run environment variable ClaudeProcess sets for every headless run: the // Same run environment variable ClaudeProcess sets for every headless run: the
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting // AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT. // it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
@@ -253,8 +269,9 @@ public sealed class InteractiveLaunchSpecService
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory -- /// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the /// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
/// directory doesn't exist.</summary> /// directory doesn't exist. A non-null <paramref name="paneId"/> pre-assigns a session id and
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct) /// appends the Mission Control handoff hint.</summary>
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, string? paneId, CancellationToken ct)
{ {
if (!Directory.Exists(directory)) if (!Directory.Exists(directory))
throw new InvalidOperationException($"Directory does not exist: {directory}"); throw new InvalidOperationException($"Directory does not exist: {directory}");
@@ -271,9 +288,166 @@ public sealed class InteractiveLaunchSpecService
}; };
// No task and no list here — the global default model's preset decides the effort. // No task and no list here — the global default model's preset decides the effort.
return new LaunchSpec( var args = WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel));
directory, resolvedClaude, if (paneId is not null)
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env); {
// Pre-assigned so the pane entry can carry it -- a handoff records it as the brief's origin.
var sessionId = Guid.NewGuid().ToString();
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args, "--session-id", sessionId];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = directory, Title = FolderTitle(directory),
}, ct);
}
return new LaunchSpec(directory, resolvedClaude, args, env);
}
/// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via
/// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool
/// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so
/// the chain can continue. With a taskId the new session id becomes the task's
/// InteractiveSessionId, so reopening the task resumes the handed-off session. Throws
/// InvalidOperationException for a missing cwd or a brief outside the pane's own dir.</summary>
public async Task<LaunchSpec> BuildHandoffAsync(
string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct)
{
if (!Directory.Exists(cwd))
throw new InvalidOperationException($"Directory does not exist: {cwd}");
briefPath = Path.GetFullPath(briefPath);
if (!briefPath.StartsWith(PaneSessionDir(paneId) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| !File.Exists(briefPath))
throw new InvalidOperationException($"Handoff brief not found: {briefPath}");
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var sessionId = Guid.NewGuid().ToString();
var model = settings.DefaultModel;
TaskEntity? task = null;
if (taskId is not null)
{
var taskRepo = new TaskRepository(ctx);
task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}");
model = task.Model ?? (await new ListRepository(ctx).GetConfigAsync(task.ListId, ct))?.Model;
await taskRepo.SetInteractiveSessionIdAsync(taskId, sessionId, ct);
}
var sessions = new MissionControlSessionRepository(ctx);
var fromSession = SessionHandoffMcpTools.ReadHandedOffFrom(briefPath);
var predecessor = fromSession is null ? null : await sessions.GetAsync(fromSession, ct);
await sessions.RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId,
Cwd = cwd,
TaskId = taskId ?? predecessor?.TaskId,
Title = predecessor?.Title ?? task?.Title ?? FolderTitle(cwd),
PredecessorSessionId = fromSession,
BriefPath = briefPath,
}, ct);
// --add-dir is variadic: the single-value flags after it end it, and the kickoff stays last.
var args = new List<string>(await PaneSessionArgsAsync(paneId, sessionId, ct))
{
"--add-dir", Path.GetDirectoryName(briefPath)!,
"--effort", EffortFor(settings, model),
"--session-id", sessionId,
$"Read the file {briefPath} first. It is the handoff brief from the session that worked here " +
"before you. After reading it, continue the work as it describes.",
};
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
}
/// <summary>Builds the LaunchSpec that resumes a recorded Mission Control session (`--resume`) in
/// its own cwd, with the handoff hint so the resumed session can hand off again. Throws
/// KeyNotFoundException for an unrecorded session, InvalidOperationException for a missing cwd.</summary>
public async Task<LaunchSpec> BuildResumeAsync(string sessionId, string paneId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var session = await new MissionControlSessionRepository(ctx).GetAsync(sessionId, ct)
?? throw new KeyNotFoundException($"Session not found: {sessionId}");
if (!Directory.Exists(session.Cwd))
throw new InvalidOperationException($"Directory does not exist: {session.Cwd}");
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
IReadOnlyList<string> args =
[
.. await PaneSessionArgsAsync(paneId, sessionId, ct),
.. WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), EffortFor(settings, settings.DefaultModel)),
];
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(session.Cwd, resolvedClaude, args, env);
}
// Same title an ad-hoc tile shows: the folder name, or the whole path for a drive root.
private static string FolderTitle(string directory) =>
Path.GetFileName(directory.TrimEnd('\\', '/')) is { Length: > 0 } name ? name : directory;
/// <summary>Per-tile dir under the app data root holding the handoff hint and every brief the
/// tile's sessions handed off with. Throws InvalidOperationException unless paneId is a GUID —
/// it becomes a path segment.</summary>
public static string PaneSessionDir(string paneId)
{
if (!Guid.TryParse(paneId, out _))
throw new InvalidOperationException($"Invalid paneId: {paneId}");
return Path.GetFullPath(Path.Combine(Paths.AppDataRoot(), "pane-sessions", paneId));
}
/// <summary>Name of the per-tile MCP entry that carries the tile's identity in its headers.</summary>
public const string PaneMcpServerName = "claudedo_pane";
// `--mcp-config <file> --append-system-prompt-file <file>` giving the session a `claudedo_pane`
// entry whose headers name this tile and session, so handoff_session needs no id from the model.
// Empty without the external MCP -- there is nothing to hand off through.
private async Task<IReadOnlyList<string>> PaneSessionArgsAsync(string paneId, string sessionId, CancellationToken ct)
{
if (_cfg.ExternalMcpPort <= 0) return [];
var dir = PaneSessionDir(paneId);
Directory.CreateDirectory(dir);
var headers = new Dictionary<string, string>
{
[SessionHandoffMcpTools.PaneHeader] = paneId,
[SessionHandoffMcpTools.SessionHeader] = sessionId,
};
if (!string.IsNullOrEmpty(_cfg.ExternalMcpApiKey))
headers["X-ClaudeDo-Key"] = _cfg.ExternalMcpApiKey;
var mcpConfigPath = Path.Combine(dir, "mcp.json");
await File.WriteAllTextAsync(mcpConfigPath, JsonSerializer.Serialize(new Dictionary<string, object>
{
["mcpServers"] = new Dictionary<string, object>
{
[PaneMcpServerName] = new { type = "http", url = $"http://127.0.0.1:{_cfg.ExternalMcpPort}/mcp", headers },
},
}), ct);
var hintPath = Path.Combine(dir, "handoff-hint.md");
await File.WriteAllTextAsync(hintPath,
$"""
You are running inside a ClaudeDo Mission Control tile. Only when the user asks you to hand off
to a fresh session, call mcp__{PaneMcpServerName}__{SessionHandoffMcpTools.ToolName} with a brief.
The brief is the ONLY context the fresh session gets, so make it self-contained: the goal, what
is done, decisions made and why, the current state (branch, uncommitted changes, failing tests),
what comes next, open questions, and the relevant file paths. This tile closes and a fresh
session opens in its place right after the call -- end your turn immediately.
""", ct);
return ["--mcp-config", mcpConfigPath, "--append-system-prompt-file", hintPath];
} }
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll, // Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
@@ -708,7 +882,8 @@ public sealed class InteractiveLaunchSpecService
"--effort", effort, "--effort", effort,
"--session-id", sessionId, "--session-id", sessionId,
$"Read the file {briefPath} first. It contains the task you must work on. " + $"Read the file {briefPath} first. It contains the task you must work on. " +
"After reading it, begin the session as your instructions describe.", "Then, before changing anything, reply with a one-line restatement of what you intend to do " +
"plus any open questions, and wait for my go-ahead.",
}; };
} }
@@ -144,4 +144,49 @@ public sealed class ListFolderTests : IDisposable
Assert.Equal(["One", "Two"], folders.Select(f => f.Name)); Assert.Equal(["One", "Two"], folders.Select(f => f.Name));
Assert.Equal([0, 1], folders.Select(f => f.SortOrder)); Assert.Equal([0, 1], folders.Select(f => f.SortOrder));
} }
[Fact]
public async Task SetFolderParentAsync_nests_a_folder_and_moves_it_back_out()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
}
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", "parent");
await using (var ctx = Open())
Assert.Equal("parent", (await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", null);
await using (var ctx = Open())
Assert.Null((await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
}
[Fact]
public async Task Deleting_a_folder_keeps_its_subfolder_and_moves_it_to_the_top_level()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
await repo.SetFolderParentAsync("child", "parent");
}
await using (var ctx = Open())
await new ListRepository(ctx).DeleteFolderAsync("parent");
await using (var ctx = Open())
{
var child = await ctx.ListFolders.AsNoTracking().SingleAsync();
Assert.Equal("child", child.Id);
Assert.Null(child.ParentFolderId);
}
}
} }
+7 -1
View File
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, string, string>? TaskQuestionAskedEvent; public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent; public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent; public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? SessionHandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent; public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent; public event Action<string, string>? HandlerUsageWarningEvent;
public event Action? NotesUpdatedEvent; public event Action? NotesUpdatedEvent;
@@ -59,6 +60,7 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId); public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds); public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath);
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message); public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
@@ -124,8 +126,12 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync( public virtual Task<string> CreateMergeHelperTaskAsync(
@@ -88,12 +88,62 @@ public class ListsIslandFolderTests : IDisposable
await vm.LoadAsync(); await vm.LoadAsync();
var folder = Assert.Single(vm.Folders); var folder = Assert.Single(vm.Folders);
Assert.Equal("Inside", Assert.Single(folder.Lists).Name); Assert.Equal("Inside", Assert.Single(folder.Children.OfType<ListNavItemViewModel>()).Name);
Assert.Equal("Outside", Assert.Single(vm.RootLists).Name); Assert.Equal("Outside", Assert.Single(vm.RootLists).Name);
// UserLists stays the flat superset — counts and row refreshes work over it. // UserLists stays the flat superset — counts and row refreshes work over it.
Assert.Equal(2, vm.UserLists.Count); Assert.Equal(2, vm.UserLists.Count);
} }
[Fact]
public async Task LoadAsync_builds_an_arbitrarily_deep_folder_tree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "mid", Name = "Mid", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 2, ParentFolderId = "mid", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "deep", Name = "Deep", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = Assert.Single(vm.Folders, f => f.ParentFolderId is null);
Assert.Equal("top", top.Id);
var mid = Assert.Single(top.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("mid", mid.Id);
var leaf = Assert.Single(mid.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("leaf", leaf.Id);
Assert.Equal("Deep", Assert.Single(leaf.Children.OfType<ListNavItemViewModel>()).Name);
// The flat recursive projection sees all three levels.
Assert.Equal(3, vm.Folders.Count);
}
[Fact]
public async Task Folder_count_sums_recursively_through_nested_subfolders()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "a", Name = "A", SortOrder = 2, FolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "b", Name = "B", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
await SeedTaskAsync("a", 1);
await SeedTaskAsync("b", 2);
await SeedTaskAsync("b", 3);
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
// 1 task directly in "a" (under top) + 2 tasks in "b" (under the nested "leaf").
Assert.Equal(3, top.Count);
Assert.Equal(2, vm.Folders.Single(f => f.Id == "leaf").Count);
}
[Fact] [Fact]
public async Task Folder_count_is_the_sum_of_its_lists() public async Task Folder_count_is_the_sum_of_its_lists()
{ {
@@ -121,7 +171,7 @@ public class ListsIslandFolderTests : IDisposable
await vm.MoveToFolderAsync(mover, vm.Folders[0]); await vm.MoveToFolderAsync(mover, vm.Folders[0]);
Assert.Empty(vm.RootLists); Assert.Empty(vm.RootLists);
Assert.Equal(2, vm.Folders[0].Lists.Count); Assert.Equal(2, vm.Folders[0].Children.Count);
Assert.Equal("f1", mover.FolderId); Assert.Equal("f1", mover.FolderId);
await using var db = NewContext(); await using var db = NewContext();
@@ -135,11 +185,11 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm(); var vm = NewVm();
await vm.LoadAsync(); await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single(); var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.MoveToFolderAsync(row, null); await vm.MoveToFolderAsync(row, null);
Assert.Empty(vm.Folders[0].Lists); Assert.Empty(vm.Folders[0].Children);
Assert.Same(row, Assert.Single(vm.RootLists)); Assert.Same(row, Assert.Single(vm.RootLists));
Assert.Null(row.FolderId); Assert.Null(row.FolderId);
@@ -156,12 +206,12 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm(); var vm = NewVm();
await vm.LoadAsync(); await vm.LoadAsync();
var mover = vm.RootLists.Single(); var mover = vm.RootLists.Single();
var inside = vm.Folders[0].Lists.Single(); var inside = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.ReorderAsync(mover, inside, placeBelow: false); await vm.ReorderAsync(mover, inside, placeBelow: false);
Assert.Empty(vm.RootLists); Assert.Empty(vm.RootLists);
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Lists.Select(l => l.Name)); Assert.Equal(["Mover", "Inside"], vm.Folders[0].Children.OfType<ListNavItemViewModel>().Select(l => l.Name));
Assert.Equal("f1", mover.FolderId); Assert.Equal("f1", mover.FolderId);
await using var db = NewContext(); await using var db = NewContext();
@@ -221,20 +271,93 @@ public class ListsIslandFolderTests : IDisposable
} }
[Fact] [Fact]
public async Task DropAsync_never_nests_a_folder_in_a_folder() public async Task DropAsync_nests_a_folder_beside_a_list_inside_another_folder()
{ {
// Folders now nest anywhere (drag-and-drop only, arbitrary depth) — dropping f2 next to a
// list that lives inside f1 moves f2 into f1's container alongside it.
await SeedAsync("f1", 0, ("inside", "Inside", 1)); await SeedAsync("f1", 0, ("inside", "Inside", 1));
await SeedAsync("f2", folderSort: 2); await SeedAsync("f2", folderSort: 2);
var vm = NewVm(); var vm = NewVm();
await vm.LoadAsync(); await vm.LoadAsync();
var inside = vm.Folders[0].Lists.Single(); var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders[1]; var f2 = vm.Folders.Single(f => f.Id == "f2");
var inside = f1.Children.OfType<ListNavItemViewModel>().Single();
await vm.DropAsync(f2, inside, placeBelow: true); await vm.DropAsync(f2, inside, placeBelow: true);
Assert.Equal(2, vm.TopLevel.Count); Assert.Equal("f1", f2.ParentFolderId);
Assert.Single(vm.Folders[0].Lists); Assert.Equal(new object[] { inside, f2 }, f1.Children);
Assert.Single(vm.TopLevel);
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task DropAsync_ignores_dropping_a_folder_beside_a_target_in_its_own_subtree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
// Would require nesting "top" inside its own descendant "child" — silently ignored.
await vm.DropAsync(top, child, placeBelow: true);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
Assert.Single(vm.TopLevel);
}
[Fact]
public async Task MoveToFolderAsync_nests_a_folder_into_another_folder_and_persists_it()
{
await SeedAsync("f1", folderSort: 0);
await SeedAsync("f2", folderSort: 1);
var vm = NewVm();
await vm.LoadAsync();
var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders.Single(f => f.Id == "f2");
await vm.MoveToFolderAsync(f2, f1);
Assert.Equal("f1", f2.ParentFolderId);
Assert.Single(vm.TopLevel);
Assert.Same(f2, Assert.Single(f1.Children));
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task MoveToFolderAsync_ignores_moving_a_folder_into_itself_or_a_descendant()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
await vm.MoveToFolderAsync(top, top);
await vm.MoveToFolderAsync(top, child);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
} }
[Fact] [Fact]
@@ -244,13 +367,14 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm(); var vm = NewVm();
await vm.LoadAsync(); await vm.LoadAsync();
var a = vm.Folders[0].Lists[0]; var children = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
var b = vm.Folders[0].Lists[1]; var (a, b) = (children[0], children[1]);
await vm.ReorderAsync(b, a, placeBelow: false); await vm.ReorderAsync(b, a, placeBelow: false);
Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name)); var reordered = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId)); Assert.Equal(["B", "A"], reordered.Select(l => l.Name));
Assert.All(reordered, l => Assert.Equal("f1", l.FolderId));
} }
[Fact] [Fact]
@@ -308,7 +432,7 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm(); var vm = NewVm();
await vm.LoadAsync(); await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single(); var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.RemoveFromFolderCommand.ExecuteAsync(row); await vm.RemoveFromFolderCommand.ExecuteAsync(row);
@@ -53,7 +53,7 @@ public class ConPtyPaneViewModelTests
[Fact] [Fact]
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning() public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
{ {
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes); using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
SetRunning(pane); SetRunning(pane);
Assert.False(pane.IsTaskBased); Assert.False(pane.IsTaskBased);
@@ -192,7 +192,7 @@ public class ConPtyPaneViewModelTests
[Fact] [Fact]
public void SessionDiedAfterStartup_AdHocPane_NeverRaises() public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
{ {
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes); using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
var raised = false; var raised = false;
pane.SessionDiedAfterStartup += (_, _) => raised = true; pane.SessionDiedAfterStartup += (_, _) => raised = true;
@@ -42,6 +42,23 @@ public class MissionControlViewModelTests : IDisposable
private MissionControlViewModel BuildVm(StubWorkerClient worker) private MissionControlViewModel BuildVm(StubWorkerClient worker)
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker)); => new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
[Fact]
public void RecentSessions_NestHandoffs_PutsIndirectHandoffsUnderTheirOrigin()
{
var t0 = new DateTime(2026, 9, 28, 8, 0, 0, DateTimeKind.Utc);
MissionControlSessionEntity S(string id, int minutes, string? from = null)
=> new() { SessionId = id, Cwd = "c", Title = id, StartedAt = t0.AddMinutes(minutes), PredecessorSessionId = from };
// Newest first, as ListRecentAsync returns them. "orphan" names a predecessor outside the window.
var newestFirst = new[] { S("c", 40, "b"), S("other", 30), S("b", 20, "a"), S("orphan", 15, "gone"), S("a", 10) };
var rows = MissionControlViewModel.NestHandoffs(newestFirst)
.Select(r => (r.Session.SessionId, r.IsHandoff)).ToList();
Assert.Equal(
new[] { ("a", false), ("b", true), ("c", true), ("other", false), ("orphan", false) },
rows);
}
// Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two // Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
// overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix // overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
// to completion before either resumes past the DB await. This reproduces the open-path races // to completion before either resumes past the DB await. This reproduces the open-path races
@@ -490,7 +507,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
{ {
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> throw new InvalidOperationException("bad directory"); => throw new InvalidOperationException("bad directory");
} }
@@ -747,6 +764,105 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(0, SubscriberCount(outgoingPane, "SubmitForReviewRequested")); Assert.Equal(0, SubscriberCount(outgoingPane, "SubmitForReviewRequested"));
} }
// The handoff replaces a tile in place -- appending it would push it to the end of the grid and
// reshuffle every other tile on every phase change.
[Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_KeepsPanePosition()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
var dir = Path.Combine("C:", "Some", "Path");
await vm.OpenAdHocConPtySessionAsync(dir);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
await vm.OpenAdHocConPtySessionAsync(dir);
var taskId = vm.ConPtySessions[1].TaskId!;
var color = vm.ConPtySessions[1].IdentityColorKey;
await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");
Assert.Equal(3, vm.ConPtySessions.Count);
Assert.Equal(taskId, vm.ConPtySessions[1].TaskId);
Assert.Equal(taskId, vm.Panes[1].TaskId);
// Same task, new pane -- the identity colour has to survive the handoff, or the tile the
// user was tracking changes colour mid-run.
Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey);
}
private sealed class RecordingSessionHandoffWorker : StubWorkerClient
{
public string? AdHocPaneId;
public readonly TaskCompletionSource<(string PaneId, string Cwd, string BriefPath, string? TaskId)> HandoffCall = new();
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
{
AdHocPaneId ??= paneId;
return base.GetAdHocLaunchSpecAsync(directory, paneId, ct);
}
public override Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
{
HandoffCall.TrySetResult((paneId, cwd, briefPath, taskId));
return base.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId, ct);
}
}
// handoff_session replaces the tile in place: same slot, same identity colour, same cwd, and the
// outgoing pane is really closed rather than left running beside the new one.
[Fact]
public async Task SessionHandoffRequested_ReplacesPaneInPlace_KeepingIdentityAndCwd()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
var dir = Path.Combine("C:", "Some", "Path");
await vm.OpenConPtySessionAsync("t1");
await vm.OpenAdHocConPtySessionAsync(dir);
await vm.OpenConPtySessionAsync("t2");
var outgoing = vm.ConPtySessions[1];
Assert.Equal(outgoing.IdentityKey, worker.AdHocPaneId);
var color = outgoing.IdentityColorKey;
var opened = 0;
vm.OpenRequested += () => opened++;
worker.RaiseSessionHandoffRequested(outgoing.IdentityKey, "brief.md");
Assert.Equal(3, vm.ConPtySessions.Count);
var incoming = vm.ConPtySessions[1];
Assert.NotSame(outgoing, incoming);
Assert.Null(incoming.TaskId);
Assert.Equal(outgoing.IdentityKey, incoming.IdentityKey);
Assert.Equal(color, incoming.IdentityColorKey);
Assert.Same(incoming, vm.Panes[1]);
Assert.Equal(1, opened);
Assert.Equal(0, SubscriberCount(outgoing, "SubmitForReviewRequested"));
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal((outgoing.IdentityKey, dir, "brief.md", (string?)null), call);
}
[Fact]
public async Task SessionHandoffRequested_TaskPane_KeepsTaskId()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
worker.RaiseSessionHandoffRequested("t1", "brief.md");
Assert.Equal("t1", Assert.Single(vm.ConPtySessions).TaskId);
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("t1", call.TaskId);
}
[Fact]
public void SessionHandoffRequested_UnknownPane_DoesNothing()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
worker.RaiseSessionHandoffRequested("nope", "brief.md");
Assert.Empty(vm.ConPtySessions);
Assert.False(worker.HandoffCall.Task.IsCompleted);
}
[Fact] [Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane() public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
{ {
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
"list_runs", "get_run", "get_task_log", "list_runs", "get_run", "get_task_log",
"list_agents", "list_agents",
"reset_failed_task", "submit_task_for_review", "reset_failed_task", "submit_task_for_review",
"handoff_list_handler", "get_handoff_status", "start_list_handler", "handoff_list_handler", "get_handoff_status", "start_list_handler", "handoff_session",
"get_app_settings", "get_app_settings",
"wait_for_task_change", "wait_for_task_change",
"get_queue_state", "get_queue_state",
@@ -0,0 +1,74 @@
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
namespace ClaudeDo.Worker.Tests.External;
public sealed class SessionHandoffMcpToolsTests : IDisposable
{
private readonly CapturingHubContext _hubContext = new();
private readonly string _paneId = Guid.NewGuid().ToString("n");
public void Dispose()
{
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(_paneId), true); } catch { }
}
private static HttpContext Request(string? paneId, string? sessionId = null)
{
var http = new DefaultHttpContext();
if (paneId is not null) http.Request.Headers[SessionHandoffMcpTools.PaneHeader] = paneId;
if (sessionId is not null) http.Request.Headers[SessionHandoffMcpTools.SessionHeader] = sessionId;
return http;
}
private SessionHandoffMcpTools BuildSut(HttpContext http) =>
new(new HubBroadcaster(_hubContext), new HttpContextAccessor { HttpContext = http });
[Fact]
public async Task HandoffSession_IdentityFromHeaders_WritesBriefWithOrigin_AndBroadcasts()
{
var result = await BuildSut(Request(_paneId, "sess-1")).HandoffSession(" do the thing ", CancellationToken.None);
Assert.True(result.Requested);
Assert.StartsWith(InteractiveLaunchSpecService.PaneSessionDir(_paneId), result.BriefPath);
Assert.Equal("Handed off from session `sess-1`.\n\ndo the thing\n", File.ReadAllText(result.BriefPath));
var call = Assert.Single(_hubContext.Proxy.Calls);
Assert.Equal("SessionHandoffRequested", call.Method);
Assert.Equal(new object?[] { _paneId, result.BriefPath }, call.Args);
}
[Theory]
[InlineData(null)]
[InlineData("not-a-guid")]
[InlineData(@"..\..\evil")]
public async Task HandoffSession_MissingOrInvalidPaneHeader_ThrowsWithoutBroadcast(string? paneId)
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut(Request(paneId)).HandoffSession("brief", CancellationToken.None));
Assert.Empty(_hubContext.Proxy.Calls);
}
[Fact]
public async Task HandoffSession_EmptyBrief_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut(Request(_paneId)).HandoffSession(" ", CancellationToken.None));
Assert.Empty(_hubContext.Proxy.Calls);
}
// The per-tile entry lists only handoff_session; the plain entry every session has hides it, so
// the tool is never duplicated and never offered where it can't identify the tile.
[Theory]
[InlineData("handoff_session", true, true)]
[InlineData("handoff_session", false, false)]
[InlineData("get_task", true, false)]
[InlineData("get_task", false, true)]
public void IsVisible_SplitsToolsByPaneHeader(string tool, bool paneRequest, bool expected)
{
Assert.Equal(expected, SessionHandoffMcpTools.IsVisible(tool, Request(paneRequest ? _paneId : null)));
}
}
@@ -66,7 +66,7 @@ public sealed class DeleteTaskHubTests : IDisposable
var (parentId, childId) = await SeedParentWithChildAsync(TaskStatus.Idle); var (parentId, childId) = await SeedParentWithChildAsync(TaskStatus.Idle);
var hub = CreateHub(); var hub = CreateHub();
await hub.DeleteTask(childId, default); await hub.DeleteTask(childId);
await using var ctx = _db.CreateContext(); await using var ctx = _db.CreateContext();
Assert.Null(await ctx.Tasks.FindAsync(childId)); Assert.Null(await ctx.Tasks.FindAsync(childId));
@@ -80,7 +80,7 @@ public sealed class DeleteTaskHubTests : IDisposable
var (_, childId) = await SeedParentWithChildAsync(TaskStatus.Running); var (_, childId) = await SeedParentWithChildAsync(TaskStatus.Running);
var hub = CreateHub(); var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(childId, default)); await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(childId));
await using var ctx = _db.CreateContext(); await using var ctx = _db.CreateContext();
Assert.NotNull(await ctx.Tasks.FindAsync(childId)); Assert.NotNull(await ctx.Tasks.FindAsync(childId));
@@ -92,7 +92,7 @@ public sealed class DeleteTaskHubTests : IDisposable
var (parentId, _) = await SeedParentWithChildAsync(TaskStatus.Idle); var (parentId, _) = await SeedParentWithChildAsync(TaskStatus.Idle);
var hub = CreateHub(); var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(parentId, default)); var ex = await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(parentId));
Assert.Contains("child tasks", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("child tasks", ex.Message, StringComparison.OrdinalIgnoreCase);
} }
@@ -102,6 +102,22 @@ public sealed class DeleteTaskHubTests : IDisposable
{ {
var hub = CreateHub(); var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask("does-not-exist", default)); await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask("does-not-exist"));
}
/// <summary>A CancellationToken on a non-streaming hub method breaks SignalR binding (every call fails with a generic server error); direct-call tests can't see that.</summary>
[Fact]
public void NoNonStreamingHubMethod_TakesCancellationToken()
{
var offenders = typeof(WorkerHub)
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly)
.Where(m => !m.IsSpecialName)
.Where(m => !(m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() is var g
&& (g == typeof(IAsyncEnumerable<>) || g == typeof(System.Threading.Channels.ChannelReader<>))))
.Where(m => m.GetParameters().Any(p => p.ParameterType == typeof(CancellationToken)))
.Select(m => m.Name)
.ToList();
Assert.Empty(offenders);
} }
} }
@@ -60,6 +60,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
try { Directory.Delete(d, true); } catch { /* best effort */ } try { Directory.Delete(d, true); } catch { /* best effort */ }
foreach (var id in _seededTaskIds) foreach (var id in _seededTaskIds)
try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ } try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ }
foreach (var id in _paneIds)
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(id), true); } catch { /* best effort */ }
} }
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
@@ -576,7 +578,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec() public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
{ {
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None); var spec = await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None);
Assert.Equal(_tempDir, spec.Cwd); Assert.Equal(_tempDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(_claudeStubPath, spec.Exe);
@@ -590,7 +592,171 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
{ {
var svc = BuildService(); var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None)); () => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), null, CancellationToken.None));
}
// ── Mission Control session handoff ──
private readonly List<string> _paneIds = new();
private string NewPaneId()
{
var id = Guid.NewGuid().ToString("n");
_paneIds.Add(id);
return id;
}
/// Asserts the spec leads with the per-tile MCP entry and the handoff hint: the entry's headers
/// name this pane and the session id the spec actually launches/resumes, and the hint names no
/// id at all (the model would guess it).
private static void AssertHandoffHint(LaunchSpec spec, string paneId, string sessionId)
{
Assert.Equal("--mcp-config", spec.Args[0]);
Assert.Equal("--append-system-prompt-file", spec.Args[2]);
using var mcp = System.Text.Json.JsonDocument.Parse(File.ReadAllText(spec.Args[1]));
var server = mcp.RootElement.GetProperty("mcpServers").GetProperty(InteractiveLaunchSpecService.PaneMcpServerName);
Assert.Equal($"http://127.0.0.1:{new WorkerConfig().ExternalMcpPort}/mcp", server.GetProperty("url").GetString());
var headers = server.GetProperty("headers");
Assert.Equal(paneId, headers.GetProperty(SessionHandoffMcpTools.PaneHeader).GetString());
Assert.Equal(sessionId, headers.GetProperty(SessionHandoffMcpTools.SessionHeader).GetString());
var hint = File.ReadAllText(spec.Args[3]);
Assert.Contains("mcp__claudedo_pane__handoff_session", hint);
Assert.DoesNotContain(paneId, hint);
Assert.DoesNotContain(sessionId, hint);
}
[Fact]
public async Task BuildForDirectoryAsync_ExternalMcpDisabled_NoPaneEntry()
{
var svc = new InteractiveLaunchSpecService(_db.CreateFactory(), _seeder, _registry,
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new GitService(), new WorkerConfig { ClaudeBin = _claudeStubPath, ExternalMcpPort = 0 },
new HandoffRoundTracker(), NewRoundState());
var spec = await svc.BuildForDirectoryAsync(_tempDir, NewPaneId(), CancellationToken.None);
Assert.DoesNotContain("--mcp-config", spec.Args);
}
private static string ArgAfter(LaunchSpec spec, string flag) => spec.Args[spec.Args.ToList().IndexOf(flag) + 1];
[Fact]
public async Task BuildForDirectoryAsync_WithPaneId_PreAssignsSessionIdAndAppendsHint()
{
var paneId = NewPaneId();
var spec = await BuildService().BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None);
var sessionId = ArgAfter(spec, "--session-id");
Assert.True(Guid.TryParse(sessionId, out _));
AssertHandoffHint(spec, paneId, sessionId);
}
[Fact]
public async Task BuildForTaskAsync_WithPaneId_ResumedSession_HintNamesResumedId()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var svc = BuildService();
var (_, _, _, firstSessionId) = ParseFreshTaskArgs(await svc.BuildForTaskAsync(taskId, CancellationToken.None));
var paneId = NewPaneId();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None, paneId);
AssertHandoffHint(spec, paneId, firstSessionId);
Assert.Equal(firstSessionId, ArgAfter(spec, "--resume"));
}
private static string WriteBrief(string paneId)
{
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "brief.md");
File.WriteAllText(path, "brief");
return path;
}
[Fact]
public async Task BuildHandoffAsync_AdHoc_KicksOffFromBriefInSameCwd()
{
var paneId = NewPaneId();
var briefPath = WriteBrief(paneId);
var spec = await BuildService().BuildHandoffAsync(paneId, _tempDir + Path.DirectorySeparatorChar, briefPath, null, CancellationToken.None);
// A trailing separator on the cwd must not survive into the spec (ConPTY quoting trap).
Assert.Equal(_tempDir, spec.Cwd);
var sessionId = ArgAfter(spec, "--session-id");
AssertHandoffHint(spec, paneId, sessionId);
Assert.Equal(Path.GetDirectoryName(briefPath), ArgAfter(spec, "--add-dir"));
var kickoff = spec.Args[^1];
Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
Assert.Equal("--session-id", spec.Args[^3]);
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
public async Task BuildHandoffAsync_Task_PersistsNewSessionIdForResume()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var briefPath = WriteBrief(taskId);
_paneIds.Add(taskId);
var spec = await BuildService().BuildHandoffAsync(taskId, _tempDir, briefPath, taskId, CancellationToken.None);
Assert.Equal(ArgAfter(spec, "--session-id"), await ReadInteractiveSessionIdAsync(taskId));
}
[Fact]
public async Task RecentSession_AdHocThenHandoff_RecordsChainAndResumesInCwd()
{
var paneId = NewPaneId();
var svc = BuildService();
var origin = ArgAfter(await svc.BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None), "--session-id");
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
Directory.CreateDirectory(dir);
var briefPath = Path.Combine(dir, "brief.md");
File.WriteAllText(briefPath, $"Handed off from session `{origin}`.\n\nbrief");
var handoff = ArgAfter(await svc.BuildHandoffAsync(paneId, _tempDir, briefPath, null, CancellationToken.None), "--session-id");
using (var ctx = _db.CreateContext())
{
var repo = new MissionControlSessionRepository(ctx);
var first = await repo.GetAsync(origin);
var second = await repo.GetAsync(handoff);
Assert.NotNull(first);
Assert.Null(first!.PredecessorSessionId);
Assert.Equal(origin, second!.PredecessorSessionId);
Assert.Equal(first.Title, second.Title);
Assert.Equal(briefPath, second.BriefPath);
}
var resumePane = NewPaneId();
var resume = await svc.BuildResumeAsync(handoff, resumePane, CancellationToken.None);
Assert.Equal(_tempDir, resume.Cwd);
Assert.Equal(handoff, ArgAfter(resume, "--resume"));
AssertHandoffHint(resume, resumePane, handoff);
}
[Fact]
public async Task BuildResumeAsync_UnknownSession_ThrowsKeyNotFound()
=> await Assert.ThrowsAsync<KeyNotFoundException>(
() => BuildService().BuildResumeAsync(Guid.NewGuid().ToString(), NewPaneId(), CancellationToken.None));
[Fact]
public async Task BuildHandoffAsync_BriefOutsidePaneDir_Throws()
{
var outside = Path.Combine(_tempDir, "brief.md");
File.WriteAllText(outside, "x");
await Assert.ThrowsAsync<InvalidOperationException>(
() => BuildService().BuildHandoffAsync(NewPaneId(), _tempDir, outside, null, CancellationToken.None));
} }
// ── Merge helper ── // ── Merge helper ──
@@ -1482,7 +1648,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var specs = new List<LaunchSpec> var specs = new List<LaunchSpec>
{ {
await svc.BuildForTaskAsync(taskId, CancellationToken.None), await svc.BuildForTaskAsync(taskId, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None), await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None),
mergeHelperSpec, mergeHelperSpec,
handoffSpec, handoffSpec,
svc.BuildPlanningStart(planningStartCtx), svc.BuildPlanningStart(planningStartCtx),