Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87dbb117d7 | ||
|
|
0f82fa67e1 | ||
|
|
9f2e63d337 | ||
|
|
3009ff0f8f | ||
|
|
0dac047c46 | ||
|
|
57dd676672 | ||
|
|
352aaa525b | ||
|
|
c8b2ad046a | ||
|
|
d3db4455fa | ||
|
|
216620c52e | ||
|
|
08d1797ead | ||
|
|
e0a1448f44 |
@@ -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
|
||||
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")
|
||||
|
||||
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
|
||||
|
||||
@@ -52,6 +52,7 @@ public partial class App : Application
|
||||
Dispatcher.UIThread.UnhandledException += (_, e) =>
|
||||
{
|
||||
e.Handled = true;
|
||||
Serilog.Log.Error(e.Exception, "Unhandled exception on the UI dispatcher");
|
||||
shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message));
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.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>
|
||||
|
||||
@@ -13,6 +13,7 @@ using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -20,6 +21,7 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ClaudeDo.App;
|
||||
|
||||
@@ -34,6 +36,35 @@ sealed class Program
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
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();
|
||||
|
||||
using (var scope = services.CreateScope())
|
||||
@@ -62,6 +93,8 @@ sealed class Program
|
||||
{
|
||||
/* best effort on shutdown */
|
||||
}
|
||||
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- ⚠️ `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`)
|
||||
@@ -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)
|
||||
- **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`.
|
||||
- **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
|
||||
|
||||
### AppSettingsEntity
|
||||
@@ -120,6 +121,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
|
||||
- `task_attachments`
|
||||
- `task_dependencies`
|
||||
- `list_folders`
|
||||
- `mission_control_sessions`
|
||||
|
||||
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,
|
||||
|
||||
@@ -54,6 +54,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
|
||||
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
|
||||
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
|
||||
public DbSet<MissionControlSessionEntity> MissionControlSessions => Set<MissionControlSessionEntity>();
|
||||
|
||||
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
|
||||
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.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
+1137
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1152
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")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("ParentFolderId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_folder_id");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -397,12 +401,54 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentFolderId")
|
||||
.HasDatabaseName("idx_list_folders_parent");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_list_folders_sort");
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -988,6 +1034,14 @@ namespace ClaudeDo.Data.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ClaudeDo.Data.Models;
|
||||
/// 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
|
||||
/// 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>
|
||||
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
|
||||
// already being read for the folder itself.
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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);
|
||||
}
|
||||
@@ -378,6 +378,12 @@
|
||||
"openSession": "Sitzung öffnen",
|
||||
"homeFolder": "Home (Schnellsitzung)",
|
||||
"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",
|
||||
"overviewMode": "Übersicht",
|
||||
"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}" },
|
||||
"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}" },
|
||||
"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}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
|
||||
@@ -378,6 +378,12 @@
|
||||
"openSession": "Open session",
|
||||
"homeFolder": "Home (quick session)",
|
||||
"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",
|
||||
"overviewMode": "Overview",
|
||||
"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}" },
|
||||
"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}" },
|
||||
"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}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"about": { "openPathFailed": "Couldn't open path: {0}" },
|
||||
|
||||
@@ -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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
||||
<PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" />
|
||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -104,6 +104,16 @@
|
||||
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
|
||||
<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 -->
|
||||
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
|
||||
<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="IslandBodyPadding">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 -->
|
||||
|
||||
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||
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
|
||||
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
|
||||
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>
|
||||
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||
/// no task, no worktree.</summary>
|
||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
|
||||
/// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint.</summary>
|
||||
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
|
||||
/// 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
|
||||
|
||||
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
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, string>? HandlerUsageWarningEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
@@ -160,6 +161,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
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) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
|
||||
@@ -298,6 +304,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
ok = true;
|
||||
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; }
|
||||
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
|
||||
}
|
||||
@@ -312,6 +323,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await invoke();
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -326,6 +342,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
ok = true;
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -620,8 +641,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
|
||||
=> 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)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
|
||||
|
||||
@@ -6,15 +6,18 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? ParentFolderId { get; set; }
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _iconKey = "Folder";
|
||||
[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;
|
||||
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
|
||||
[ObservableProperty] private bool _isDropTarget;
|
||||
@@ -23,5 +26,5 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[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>
|
||||
public ObservableCollection<object> TopLevel { get; } = new();
|
||||
|
||||
/// <summary>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second
|
||||
/// collection to keep in sync.</summary>
|
||||
public IReadOnlyList<ListFolderNavItemViewModel> Folders =>
|
||||
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
|
||||
/// <summary>Every sidebar folder, at any depth — a recursive projection of
|
||||
/// <see cref="TopLevel"/>, not a second collection to keep in sync.</summary>
|
||||
public IReadOnlyList<ListFolderNavItemViewModel> Folders => AllFolders(TopLevel).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>
|
||||
public IReadOnlyList<ListNavItemViewModel> RootLists =>
|
||||
TopLevel.OfType<ListNavItemViewModel>().ToList();
|
||||
@@ -320,10 +330,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
|
||||
// Folders and top-level lists share one sort_order space, so both go into a single
|
||||
// ordered bucket and get merged by it — that's what interleaves them.
|
||||
var topLevel = new List<(int Sort, object Item)>();
|
||||
// Every container (the top level, and each folder's children) gets its own ordered
|
||||
// bucket keyed by sort_order, since folders and lists at any depth share one sort_order
|
||||
// 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>();
|
||||
|
||||
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))
|
||||
{
|
||||
var folder = new ListFolderNavItemViewModel
|
||||
@@ -332,9 +350,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
Name = f.Name,
|
||||
IconKey = f.Icon ?? ListFolderIcon,
|
||||
IsCollapsed = f.IsCollapsed,
|
||||
ParentFolderId = f.ParentFolderId,
|
||||
};
|
||||
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" });
|
||||
@@ -357,16 +378,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner))
|
||||
owner.Lists.Add(item);
|
||||
else
|
||||
topLevel.Add((l.SortOrder, item));
|
||||
BucketFor(item.FolderId).Add((l.SortOrder, item));
|
||||
}
|
||||
|
||||
// OrderBy is stable, so legacy rows (folders and lists each numbered from 0
|
||||
// independently) keep the old look — folders first — until the first drag.
|
||||
foreach (var (_, item) in topLevel.OrderBy(t => t.Sort))
|
||||
TopLevel.Add(item);
|
||||
void Materialize(List<(int Sort, object Item)> bucket, System.Collections.IList target)
|
||||
{
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
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>
|
||||
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
|
||||
@@ -528,10 +570,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// only way to drag a list out of a folder. Folders live at the top level only, so a folder
|
||||
/// dropped onto a row inside a folder is ignored rather than nested.
|
||||
/// only way to drag something out of a folder. A folder dropped next to a target inside its own
|
||||
/// subtree is ignored rather than nested into itself.
|
||||
/// </summary>
|
||||
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 (target is ListNavItemViewModel { FolderId: not null }) return;
|
||||
MoveWithinCollection(TopLevel, folder, target, placeBelow);
|
||||
await PersistOrderAsync(null);
|
||||
var newParentId = target switch
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
// drop-into-the-folder path.
|
||||
var targetFolderId = target is ListNavItemViewModel t ? t.FolderId : null;
|
||||
// Next to a folder *header* means that folder's own container, not inside it —
|
||||
// MoveToFolderAsync is the drop-into-the-folder path.
|
||||
var targetFolderId = target switch
|
||||
{
|
||||
ListNavItemViewModel t => t.FolderId,
|
||||
ListFolderNavItemViewModel tf => tf.ParentFolderId,
|
||||
_ => null,
|
||||
};
|
||||
var folderChanged = row.FolderId != targetFolderId;
|
||||
if (folderChanged)
|
||||
{
|
||||
@@ -563,40 +631,58 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await PersistOrderAsync(folderChanged ? row : null);
|
||||
}
|
||||
|
||||
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
|
||||
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary>
|
||||
public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder)
|
||||
/// <summary>Appends a row (list or folder) to <paramref name="folder"/> (or to the top level
|
||||
/// when null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty
|
||||
/// 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.FolderId == folder?.Id) return;
|
||||
if (source is ListFolderNavItemViewModel sourceFolder)
|
||||
{
|
||||
if (ReferenceEquals(sourceFolder, folder)) return;
|
||||
if (IsSelfOrDescendant(sourceFolder, folder?.Id)) return;
|
||||
if (sourceFolder.ParentFolderId == folder?.Id) return;
|
||||
|
||||
ContainerOf(source).Remove(source);
|
||||
source.FolderId = folder?.Id;
|
||||
ContainerOf(source).Add(source);
|
||||
await PersistOrderAsync(source);
|
||||
ContainerOf(sourceFolder).Remove(sourceFolder);
|
||||
sourceFolder.ParentFolderId = folder?.Id;
|
||||
ContainerOf(sourceFolder).Add(sourceFolder);
|
||||
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>
|
||||
/// 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
|
||||
/// <c>folder_id</c>.
|
||||
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence,
|
||||
/// walked depth-first (a folder, then its children, at any depth) and, when a row changed
|
||||
/// container, its new <c>folder_id</c>/<c>parent_folder_id</c>.
|
||||
/// </summary>
|
||||
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
|
||||
private async Task PersistOrderAsync(object? movedItem)
|
||||
{
|
||||
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:
|
||||
ordered.Add((f.Id, true));
|
||||
ordered.AddRange(f.Lists.Select(l => (RawId(l.Id), false)));
|
||||
break;
|
||||
case ListNavItemViewModel l:
|
||||
ordered.Add((RawId(l.Id), false));
|
||||
break;
|
||||
switch (item)
|
||||
{
|
||||
case ListFolderNavItemViewModel f:
|
||||
ordered.Add((f.Id, true));
|
||||
Walk(f.Children);
|
||||
break;
|
||||
case ListNavItemViewModel l:
|
||||
ordered.Add((RawId(l.Id), false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Walk(TopLevel);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ok = false;
|
||||
@@ -604,8 +690,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
if (movedRow is not null)
|
||||
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
|
||||
switch (movedItem)
|
||||
{
|
||||
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);
|
||||
ok = true;
|
||||
}
|
||||
@@ -671,7 +764,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
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) =>
|
||||
@@ -805,8 +913,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
.Select(l => l.Id)
|
||||
.ToList()
|
||||
.IndexOf(entity.Id);
|
||||
folder.Lists.Insert(
|
||||
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx,
|
||||
folder.Children.Insert(
|
||||
containerIdx < 0 || containerIdx > folder.Children.Count ? folder.Children.Count : containerIdx,
|
||||
item);
|
||||
}
|
||||
else TopLevel.Add(item);
|
||||
|
||||
@@ -208,6 +208,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void FlashFooter(string message, WorkerLogLevel level)
|
||||
{
|
||||
if (level == WorkerLogLevel.Error)
|
||||
Serilog.Log.Warning("Footer error: {message}", message);
|
||||
|
||||
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
|
||||
WorkerLogLevel = level;
|
||||
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).
|
||||
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
|
||||
/// 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
|
||||
@@ -60,7 +79,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
public event Action<string, int>? SessionDiedAfterStartup;
|
||||
|
||||
/// <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"/>)
|
||||
/// 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>
|
||||
@@ -70,6 +89,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||
{
|
||||
TaskId = taskId;
|
||||
IdentityKey = taskId ?? Guid.NewGuid().ToString("n");
|
||||
_displayTitle = displayTitle;
|
||||
_descriptorFactory = descriptorFactory;
|
||||
Subscribe(Terminal);
|
||||
@@ -112,11 +132,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
|
||||
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()
|
||||
{
|
||||
try
|
||||
{
|
||||
Terminal.Start(await _descriptorFactory());
|
||||
var descriptor = await _descriptorFactory();
|
||||
Cwd = descriptor.Cwd;
|
||||
Terminal.Start(descriptor);
|
||||
}
|
||||
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)
|
||||
{
|
||||
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.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
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.
|
||||
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
|
||||
// MissionControlView.OnNewSessionClicked).
|
||||
public Action? BrowseForSessionRequested { get; set; }
|
||||
@@ -108,6 +116,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
||||
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
||||
_worker.SessionHandoffRequestedEvent += OpenSessionHandoff;
|
||||
|
||||
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
|
||||
_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)); }
|
||||
}
|
||||
|
||||
/// <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
|
||||
// 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
|
||||
@@ -322,11 +409,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
||||
if (string.IsNullOrEmpty(title)) title = directory;
|
||||
|
||||
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
||||
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
|
||||
// The key is minted here, not in the pane, because the launch spec needs it as the paneId.
|
||||
var paneId = Guid.NewGuid().ToString("n");
|
||||
AddConPtyPane(new ConPtyPaneViewModel(null, title,
|
||||
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory, paneId))) { IdentityKey = paneId });
|
||||
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:
|
||||
// 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
|
||||
@@ -408,6 +515,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
||||
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())
|
||||
CloseConPtySession(stale);
|
||||
|
||||
@@ -428,7 +540,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => 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
|
||||
@@ -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,
|
||||
// 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.CloseRequested += OnPaneCloseRequested;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||
ConPtySessions.Add(pane);
|
||||
ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane);
|
||||
pane.Start();
|
||||
}
|
||||
|
||||
@@ -610,6 +722,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.HandoffRequestedEvent -= _onHandoffRequested;
|
||||
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
|
||||
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
|
||||
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||
@@ -647,6 +760,20 @@ public sealed class QueuedTaskViewModel
|
||||
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>
|
||||
public sealed class SessionTargetViewModel
|
||||
{
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Lists}"
|
||||
<ItemsControl ItemsSource="{Binding Children}"
|
||||
IsVisible="{Binding !IsCollapsed}"
|
||||
Margin="14,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
@@ -142,18 +142,21 @@ public partial class ListsIslandView : UserControl
|
||||
private enum FolderDrop { None, Into, Beside }
|
||||
|
||||
/// <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
|
||||
/// folder at the top level; a folder source has no "into" — folders never nest — so it splits
|
||||
/// in half.
|
||||
/// folder in its own container. A folder dropped onto itself or one of its own subfolders has
|
||||
/// no valid "into" or "beside" and is rejected.
|
||||
/// </summary>
|
||||
private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop(
|
||||
object source, ListFolderNavItemViewModel folder, double y, double height)
|
||||
{
|
||||
if (source is ListFolderNavItemViewModel dragged)
|
||||
return ReferenceEquals(dragged, folder)
|
||||
? (FolderDrop.None, false)
|
||||
: (FolderDrop.Beside, y > height / 2);
|
||||
{
|
||||
if (IsSelfOrDescendant(dragged, folder)) return (FolderDrop.None, false);
|
||||
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 (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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
vm.ClearDropHints();
|
||||
if (kind == FolderDrop.Into && source is ListNavItemViewModel row)
|
||||
await vm.MoveToFolderAsync(row, folder);
|
||||
if (kind == FolderDrop.Into)
|
||||
await vm.MoveToFolderAsync(source, folder);
|
||||
else if (kind == FolderDrop.Beside)
|
||||
await vm.DropAsync(source, folder, placeBelow);
|
||||
}
|
||||
@@ -224,8 +235,6 @@ public partial class ListsIslandView : UserControl
|
||||
|
||||
var source = DragSource(vm, e);
|
||||
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.
|
||||
object hintTarget = target;
|
||||
@@ -251,7 +260,6 @@ public partial class ListsIslandView : UserControl
|
||||
|
||||
var source = DragSource(vm, e);
|
||||
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;
|
||||
vm.ClearDropHints();
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
xmlns:views="using:ClaudeDo.Ui.Views"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
xmlns:cconv="using:ClaudeDo.Ui.Converters"
|
||||
x:DataType="vm:ConPtyPaneViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
|
||||
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="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 -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
|
||||
@@ -48,6 +48,60 @@
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</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"
|
||||
Command="{Binding ToggleLayoutCommand}"
|
||||
ToolTip.Tip="{Binding LayoutToggleLabel}">
|
||||
@@ -84,7 +138,11 @@
|
||||
IsVisible="{Binding IsFocusMode}">
|
||||
<TabControl.ItemTemplate>
|
||||
<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>
|
||||
</TabControl.ItemTemplate>
|
||||
</TabControl>
|
||||
|
||||
@@ -29,10 +29,14 @@ public partial class MissionControlView : UserControl
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
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 OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
|
||||
|
||||
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
|
||||
private async void BrowseForSessionAsync()
|
||||
{
|
||||
|
||||
@@ -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) =>
|
||||
_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
|
||||
// tile, exactly like the "Let Claude handle it" button.
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +277,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
// TryAdvanceParentAsync side effect — a direct-repo delete from the details pane
|
||||
// used to skip it, permanently wedging a WaitingForChildren parent whose last
|
||||
// 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);
|
||||
var repo = new TaskRepository(ctx);
|
||||
var task = await repo.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -776,16 +779,34 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
_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 --
|
||||
// no task, no worktree, no session-skills seeding.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
|
||||
// no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered
|
||||
// into the handoff hint.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
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
|
||||
|
||||
@@ -28,6 +28,7 @@ using ClaudeDo.Worker.Usage.Interfaces;
|
||||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||||
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
|
||||
@@ -58,6 +59,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", 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(
|
||||
System.IO.Path.Combine(logRoot, "worker-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
@@ -90,7 +96,11 @@ builder.Services.AddHostedService<UntrackedWorktreeRecovery>();
|
||||
// Default MaximumParallelInvocationsPerClient=1 serializes every hub call on one connection —
|
||||
// a slow one (e.g. GetTicketProjects hitting an unreachable ticket system) blocks unrelated
|
||||
// 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 =>
|
||||
{
|
||||
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<LifecycleMcpTools>();
|
||||
externalBuilder.Services.AddScoped<HandoffMcpTools>();
|
||||
externalBuilder.Services.AddScoped<SessionHandoffMcpTools>();
|
||||
externalBuilder.Services.AddHttpContextAccessor();
|
||||
externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
|
||||
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
||||
@@ -385,7 +397,9 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddScoped<NotesMcpTools>();
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport(o => o.Stateless = true)
|
||||
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
||||
.WithRequestFilters(f => f
|
||||
.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)
|
||||
.AddListToolsFilter(SessionHandoffMcpTools.FilterByPane))
|
||||
.WithTools<ExternalMcpService>()
|
||||
.WithTools<MergeMcpTools>()
|
||||
.WithTools<WorktreeMcpTools>()
|
||||
@@ -396,6 +410,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
.WithTools<AgentMcpTools>()
|
||||
.WithTools<LifecycleMcpTools>()
|
||||
.WithTools<HandoffMcpTools>()
|
||||
.WithTools<SessionHandoffMcpTools>()
|
||||
.WithTools<ListHandlerMcpTools>()
|
||||
.WithTools<AppSettingsMcpTools>()
|
||||
.WithTools<TaskWaitMcpTools>()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
@@ -43,6 +44,7 @@ public sealed class InteractiveLaunchSpecService
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly GitService _git;
|
||||
private readonly string _claudePath;
|
||||
private readonly WorkerConfig _cfg;
|
||||
private readonly HandoffRoundTracker _handoffRounds;
|
||||
private readonly HandlerRoundState _roundState;
|
||||
|
||||
@@ -67,6 +69,7 @@ public sealed class InteractiveLaunchSpecService
|
||||
_wtManager = wtManager;
|
||||
_git = git;
|
||||
_claudePath = cfg.ClaudeBin;
|
||||
_cfg = cfg;
|
||||
_handoffRounds = handoffRounds;
|
||||
_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
|
||||
/// 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
|
||||
/// launch, so a closed/aborted session can be resumed next time.</summary>
|
||||
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
|
||||
/// launch, so a closed/aborted session can be resumed next time. A non-null
|
||||
/// <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);
|
||||
|
||||
@@ -184,17 +189,28 @@ public sealed class InteractiveLaunchSpecService
|
||||
// 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.
|
||||
IReadOnlyList<string> args;
|
||||
string sessionId;
|
||||
if (resumeSessionId is { Length: > 0 })
|
||||
{
|
||||
sessionId = resumeSessionId;
|
||||
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
|
||||
}
|
||||
else
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
sessionId = Guid.NewGuid().ToString();
|
||||
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, 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
|
||||
// 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.
|
||||
@@ -253,8 +269,9 @@ public sealed class InteractiveLaunchSpecService
|
||||
|
||||
/// <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
|
||||
/// directory doesn't exist.</summary>
|
||||
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
||||
/// directory doesn't exist. A non-null <paramref name="paneId"/> pre-assigns a session id and
|
||||
/// appends the Mission Control handoff hint.</summary>
|
||||
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, string? paneId, CancellationToken ct)
|
||||
{
|
||||
if (!Directory.Exists(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.
|
||||
return new LaunchSpec(
|
||||
directory, resolvedClaude,
|
||||
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
|
||||
var args = WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel));
|
||||
if (paneId is not null)
|
||||
{
|
||||
// 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,
|
||||
@@ -708,7 +882,8 @@ public sealed class InteractiveLaunchSpecService
|
||||
"--effort", effort,
|
||||
"--session-id", sessionId,
|
||||
$"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([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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
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, string>? HandlerUsageWarningEvent;
|
||||
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 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 RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath);
|
||||
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
|
||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool 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<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> 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>()));
|
||||
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)
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<string> CreateMergeHelperTaskAsync(
|
||||
|
||||
@@ -88,12 +88,62 @@ public class ListsIslandFolderTests : IDisposable
|
||||
await vm.LoadAsync();
|
||||
|
||||
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);
|
||||
// UserLists stays the flat superset — counts and row refreshes work over it.
|
||||
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]
|
||||
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]);
|
||||
|
||||
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);
|
||||
|
||||
await using var db = NewContext();
|
||||
@@ -135,11 +185,11 @@ public class ListsIslandFolderTests : IDisposable
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var row = vm.Folders[0].Lists.Single();
|
||||
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
|
||||
|
||||
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.Null(row.FolderId);
|
||||
|
||||
@@ -156,12 +206,12 @@ public class ListsIslandFolderTests : IDisposable
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
await using var db = NewContext();
|
||||
@@ -221,20 +271,93 @@ public class ListsIslandFolderTests : IDisposable
|
||||
}
|
||||
|
||||
[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("f2", folderSort: 2);
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var inside = vm.Folders[0].Lists.Single();
|
||||
var f2 = vm.Folders[1];
|
||||
var f1 = vm.Folders.Single(f => f.Id == "f1");
|
||||
var f2 = vm.Folders.Single(f => f.Id == "f2");
|
||||
var inside = f1.Children.OfType<ListNavItemViewModel>().Single();
|
||||
|
||||
await vm.DropAsync(f2, inside, placeBelow: true);
|
||||
|
||||
Assert.Equal(2, vm.TopLevel.Count);
|
||||
Assert.Single(vm.Folders[0].Lists);
|
||||
Assert.Equal("f1", f2.ParentFolderId);
|
||||
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]
|
||||
@@ -244,13 +367,14 @@ public class ListsIslandFolderTests : IDisposable
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var a = vm.Folders[0].Lists[0];
|
||||
var b = vm.Folders[0].Lists[1];
|
||||
var children = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
|
||||
var (a, b) = (children[0], children[1]);
|
||||
|
||||
await vm.ReorderAsync(b, a, placeBelow: false);
|
||||
|
||||
Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name));
|
||||
Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId));
|
||||
var reordered = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
|
||||
Assert.Equal(["B", "A"], reordered.Select(l => l.Name));
|
||||
Assert.All(reordered, l => Assert.Equal("f1", l.FolderId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -308,7 +432,7 @@ public class ListsIslandFolderTests : IDisposable
|
||||
|
||||
var vm = NewVm();
|
||||
await vm.LoadAsync();
|
||||
var row = vm.Folders[0].Lists.Single();
|
||||
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
|
||||
|
||||
await vm.RemoveFromFolderCommand.ExecuteAsync(row);
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public class ConPtyPaneViewModelTests
|
||||
[Fact]
|
||||
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);
|
||||
|
||||
Assert.False(pane.IsTaskBased);
|
||||
@@ -192,7 +192,7 @@ public class ConPtyPaneViewModelTests
|
||||
[Fact]
|
||||
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;
|
||||
pane.SessionDiedAfterStartup += (_, _) => raised = true;
|
||||
|
||||
|
||||
@@ -42,6 +42,23 @@ public class MissionControlViewModelTests : IDisposable
|
||||
private MissionControlViewModel BuildVm(StubWorkerClient 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
|
||||
// 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
|
||||
@@ -490,7 +507,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -747,6 +764,105 @@ public class MissionControlViewModelTests : IDisposable
|
||||
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]
|
||||
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
|
||||
{
|
||||
|
||||
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
|
||||
"list_runs", "get_run", "get_task_log",
|
||||
"list_agents",
|
||||
"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",
|
||||
"wait_for_task_change",
|
||||
"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 hub = CreateHub();
|
||||
|
||||
await hub.DeleteTask(childId, default);
|
||||
await hub.DeleteTask(childId);
|
||||
|
||||
await using var ctx = _db.CreateContext();
|
||||
Assert.Null(await ctx.Tasks.FindAsync(childId));
|
||||
@@ -80,7 +80,7 @@ public sealed class DeleteTaskHubTests : IDisposable
|
||||
var (_, childId) = await SeedParentWithChildAsync(TaskStatus.Running);
|
||||
var hub = CreateHub();
|
||||
|
||||
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(childId, default));
|
||||
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(childId));
|
||||
|
||||
await using var ctx = _db.CreateContext();
|
||||
Assert.NotNull(await ctx.Tasks.FindAsync(childId));
|
||||
@@ -92,7 +92,7 @@ public sealed class DeleteTaskHubTests : IDisposable
|
||||
var (parentId, _) = await SeedParentWithChildAsync(TaskStatus.Idle);
|
||||
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);
|
||||
}
|
||||
@@ -102,6 +102,22 @@ public sealed class DeleteTaskHubTests : IDisposable
|
||||
{
|
||||
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 */ }
|
||||
foreach (var id in _seededTaskIds)
|
||||
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
|
||||
@@ -576,7 +578,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
|
||||
{
|
||||
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(_claudeStubPath, spec.Exe);
|
||||
@@ -590,7 +592,171 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
{
|
||||
var svc = BuildService();
|
||||
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 ──
|
||||
@@ -1482,7 +1648,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var specs = new List<LaunchSpec>
|
||||
{
|
||||
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
|
||||
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
|
||||
await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None),
|
||||
mergeHelperSpec,
|
||||
handoffSpec,
|
||||
svc.BuildPlanningStart(planningStartCtx),
|
||||
|
||||
Reference in New Issue
Block a user