diff --git a/docs/explore-notes/conpty-sessions.md b/docs/explore-notes/conpty-sessions.md index 4b7ddd05..92725c7f 100644 --- a/docs/explore-notes/conpty-sessions.md +++ b/docs/explore-notes/conpty-sessions.md @@ -124,6 +124,21 @@ with the same `IdentityKey` via `GetSessionHandoffLaunchSpec` → `BuildHandoffA 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 ` 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 diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 2fb99e15..beba0d51 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -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, diff --git a/src/ClaudeDo.Data/ClaudeDoDbContext.cs b/src/ClaudeDo.Data/ClaudeDoDbContext.cs index 7410043f..66488784 100644 --- a/src/ClaudeDo.Data/ClaudeDoDbContext.cs +++ b/src/ClaudeDo.Data/ClaudeDoDbContext.cs @@ -54,6 +54,7 @@ public class ClaudeDoDbContext : DbContext public DbSet DailyNotes => Set(); public DbSet WeekReports => Set(); public DbSet SessionSkills => Set(); + public DbSet MissionControlSessions => Set(); private static readonly ValueConverter UtcConverter = new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc)); diff --git a/src/ClaudeDo.Data/Configuration/MissionControlSessionEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/MissionControlSessionEntityConfiguration.cs new file mode 100644 index 00000000..47a340e6 --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/MissionControlSessionEntityConfiguration.cs @@ -0,0 +1,22 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class MissionControlSessionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260928100316_AddMissionControlSessions.Designer.cs b/src/ClaudeDo.Data/Migrations/20260928100316_AddMissionControlSessions.Designer.cs new file mode 100644 index 00000000..58c0cef2 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260928100316_AddMissionControlSessions.Designer.cs @@ -0,0 +1,1137 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260928100316_AddMissionControlSessions")] + partial class AddMissionControlSessions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("ListHandlerMaxHandoffs") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4) + .HasColumnName("list_handler_max_handoffs"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property("PowerUseEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("power_use_enabled"); + + b.Property("PowerUseHandoffPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(95) + .HasColumnName("power_use_handoff_pct"); + + b.Property("PowerUseWindowEnd") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_end"); + + b.Property("PowerUseWindowStart") + .HasColumnType("TEXT") + .HasColumnName("power_use_window_start"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + AutoContinueOnUsageLimit = false, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + ListHandlerMaxHandoffs = 4, + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + PowerUseEnabled = false, + PowerUseHandoffPct = 95, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("FromAgent") + .HasColumnType("INTEGER") + .HasColumnName("from_agent"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PowerUseAllowed") + .HasColumnType("INTEGER") + .HasColumnName("power_use_allowed"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property("FolderId") + .HasColumnType("TEXT") + .HasColumnName("folder_id"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("FolderId") + .HasDatabaseName("idx_lists_folder"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsCollapsed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_collapsed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_list_folders_sort"); + + b.ToTable("list_folders", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b => + { + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("BriefPath") + .HasColumnType("TEXT") + .HasColumnName("brief_path"); + + b.Property("Cwd") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cwd"); + + b.Property("PredecessorSessionId") + .HasColumnType("TEXT") + .HasColumnName("predecessor_session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("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("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_task_dependencies_depends_on"); + + b.ToTable("task_dependencies", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("ExpectsNoDiff") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("expects_no_diff"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Number") + .IsUnique() + .HasDatabaseName("idx_tasks_number"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany("Dependencies") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Dependencies"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260928100316_AddMissionControlSessions.cs b/src/ClaudeDo.Data/Migrations/20260928100316_AddMissionControlSessions.cs new file mode 100644 index 00000000..703e404f --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260928100316_AddMissionControlSessions.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + public partial class AddMissionControlSessions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "mission_control_sessions", + columns: table => new + { + session_id = table.Column(type: "TEXT", nullable: false), + cwd = table.Column(type: "TEXT", nullable: false), + task_id = table.Column(type: "TEXT", nullable: true), + title = table.Column(type: "TEXT", nullable: false), + started_at = table.Column(type: "TEXT", nullable: false), + predecessor_session_id = table.Column(type: "TEXT", nullable: true), + brief_path = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "mission_control_sessions"); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index f86bbbf6..3dce8922 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -403,6 +403,45 @@ namespace ClaudeDo.Data.Migrations b.ToTable("list_folders", (string)null); }); + modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b => + { + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("BriefPath") + .HasColumnType("TEXT") + .HasColumnName("brief_path"); + + b.Property("Cwd") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cwd"); + + b.Property("PredecessorSessionId") + .HasColumnType("TEXT") + .HasColumnName("predecessor_session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("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("Id") diff --git a/src/ClaudeDo.Data/Models/MissionControlSessionEntity.cs b/src/ClaudeDo.Data/Models/MissionControlSessionEntity.cs new file mode 100644 index 00000000..424a9386 --- /dev/null +++ b/src/ClaudeDo.Data/Models/MissionControlSessionEntity.cs @@ -0,0 +1,27 @@ +namespace ClaudeDo.Data.Models; + +/// 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. +public sealed class MissionControlSessionEntity +{ + /// The claude session id (`--session-id` / `--resume`). + public string SessionId { get; init; } = string.Empty; + + /// Working directory the session ran in — a resume must start there. + public string Cwd { get; set; } = string.Empty; + + /// Task the tile belonged to; null for an ad-hoc tile. No FK: the row outlives the task. + public string? TaskId { get; set; } + + /// Tile title at start (task title or folder name; a handoff inherits its predecessor's). + public string Title { get; set; } = string.Empty; + + /// UTC time the session was first launched. + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + + /// Session this one was handed off from; null unless it is a handoff session. + public string? PredecessorSessionId { get; set; } + + /// brief.md the session started from; non-null exactly for handoff sessions. + public string? BriefPath { get; set; } +} diff --git a/src/ClaudeDo.Data/Repositories/MissionControlSessionRepository.cs b/src/ClaudeDo.Data/Repositories/MissionControlSessionRepository.cs new file mode 100644 index 00000000..572694e1 --- /dev/null +++ b/src/ClaudeDo.Data/Repositories/MissionControlSessionRepository.cs @@ -0,0 +1,34 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Data.Repositories; + +/// Reads and writes mission_control_sessions. +public sealed class MissionControlSessionRepository +{ + private readonly ClaudeDoDbContext _context; + + public MissionControlSessionRepository(ClaudeDoDbContext context) => _context = context; + + /// Inserts the session unless it is already recorded — a resumed task session keeps + /// its original row and start time. + 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); + } + + /// The recorded session, or null. + public async Task GetAsync(string sessionId, CancellationToken ct = default) => + await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct); + + /// Sessions started since , newest first, at most . + public async Task> 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); +} diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 662bf3b2..7bb9a73d 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -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", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8230010c..0ae54c2b 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -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", diff --git a/src/ClaudeDo.Ui/Design/Tokens.axaml b/src/ClaudeDo.Ui/Design/Tokens.axaml index 21502cf9..38e86ff8 100644 --- a/src/ClaudeDo.Ui/Design/Tokens.axaml +++ b/src/ClaudeDo.Ui/Design/Tokens.axaml @@ -158,6 +158,7 @@ 18,16,18,12 14 14 + 18,0,0,0 diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index a7d2f938..6286b29b 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -141,6 +141,8 @@ public interface IWorkerClient : INotifyPropertyChanged Task GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default); /// Launch spec for the fresh session a tile hands off to after handoff_session. Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default); + /// Launch spec resuming a recorded Mission Control session in its cwd, as tile paneId. + Task GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default); /// 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 diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index c4c37017..7943e390 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -632,6 +632,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default) => await InvokeTimedAsync("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct)); + public async Task GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default) + => await InvokeTimedAsync("GetResumeSessionLaunchSpec", () => _hub.InvokeAsync("GetResumeSessionLaunchSpec", sessionId, paneId, ct)); + public async Task GetMergeHelperLaunchSpecAsync(IReadOnlyList taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default) => await InvokeTimedAsync("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct)); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 5756b1c3..9387e6a6 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -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 SessionTargets { get; } = new(); + /// "Recent sessions" flyout rows: each chain's origin session, its handoffs right below it. + public ObservableCollection RecentSessions { get; } = new(); + + /// False shows the flyout's empty-state line. + 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; } @@ -215,6 +223,84 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } } + /// Rebuilds from the last 14 days (max 50 sessions); runs on + /// every flyout open. + 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)); + } + + /// 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. + internal static IEnumerable<(MissionControlSessionEntity Session, bool IsHandoff)> NestHandoffs( + IReadOnlyList newestFirst) + { + var byId = newestFirst.ToDictionary(s => s.SessionId); + string RootOf(MissionControlSessionEntity s) + { + var seen = new HashSet(); + 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 @@ -674,6 +760,20 @@ public sealed class QueuedTaskViewModel public IRelayCommand? OpenSessionCommand { get; init; } } +/// One row in the "Recent sessions" flyout. +public sealed class RecentSessionViewModel +{ + public required string Title { get; init; } + /// Start time plus cwd (origin) or the handoff label (handoff). + public required string Meta { get; init; } + /// Indents the row under its chain's origin session. + public bool IsHandoff { get; init; } + public required IRelayCommand ResumeCommand { get; init; } + /// Opens the session's brief.md; null for a session that did not start from one. + public IRelayCommand? ShowBriefCommand { get; init; } + public bool HasBrief => ShowBriefCommand is not null; +} + /// One entry in the "new session" menu: a configured list's working dir, or browse. public sealed class SessionTargetViewModel { diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index 95b2a1f7..9096d5fe 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -48,6 +48,60 @@ +