Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f27fc3049 | ||
|
|
23ed2b4543 | ||
|
|
f37b59d58f | ||
|
|
43b03e4837 |
+112
@@ -1,5 +1,117 @@
|
||||
# Changelog
|
||||
|
||||
## v2.19.0 — 2026-10-02
|
||||
|
||||
### Features
|
||||
|
||||
- resolve conflicts straight from the merge modal (214e53b7)
|
||||
- ssh workspaces settings tab (89ed8852)
|
||||
- ssh workspaces as quick-session targets in mission control (6c400dfb)
|
||||
- ssh launch spec with reverse-tunnelled claudedo MCP (1b58e628)
|
||||
- remote MCP listener gated by per-session tokens (34970ca6)
|
||||
- store ssh workspaces on app_settings (03143198)
|
||||
- folder/file-type icons and change-kind colours in the file tree (ae778d5c)
|
||||
- offer Mark as Idle for cancelled tasks (3fab4016)
|
||||
- attach clipboard image on Ctrl+V in the task description (393e5946)
|
||||
- rename sessions inline in the Recent sessions flyout (5bf08873)
|
||||
- merge helper knows company-repo approve ends in a draft PR (0e6a48e2)
|
||||
- external services tab with Azure DevOps PAT, draft PR button and link in company repos (4976ded7)
|
||||
- show running Worker operations in a Running now section (2dbec543)
|
||||
- Azure DevOps PAT settings and connection test (fed0b6ac)
|
||||
- approve in a company repo verifies in the worktree, pushes and opens a draft PR (bc959f25)
|
||||
- company unit roots start from a fetched origin base, local base fast-forwarded best-effort (7f7f2376)
|
||||
- Azure DevOps client and PAT store, ported from pr-create.ps1 (78f4d182)
|
||||
- fetch/push/ff primitives with auth env, no task trailer in company repos (76f260a9)
|
||||
- worktrees.pull_request_url for the company-repo draft PR (bc0aa28a)
|
||||
- show task number in Mission Control activity strip (f66cbca6)
|
||||
- combined diff of a unit is base..head, no integration branch checkout (44d94caa)
|
||||
- approve a unit parent like a single task, orchestrator only for legacy units (1f373a5a)
|
||||
- fast-forward finished children into the parent's unit branch (9da522b0)
|
||||
- add ff-only merge and allow a merged worktree without merge commit (724ad90f)
|
||||
- colour agent output by kind and stop cutting tool calls at 120 chars (9bb0e67a)
|
||||
- base branch field in list settings, real branch names in dialogs (ae845bf0)
|
||||
- use the list base branch as worktree base and default merge target (69c7803b)
|
||||
- per-list base branch, preserved by every config writer (3fe1fead)
|
||||
- name task branches <type>/<number>-<slug> (814b083a)
|
||||
- branch type and number rules for task branches (4a9cff55)
|
||||
- selectable list/folder colors with accent-aware swatches and custom picker (a6926f53)
|
||||
- detect company repos by origin remote pattern (faa91e06)
|
||||
- stream claude update progress into the footer strip (2b19c8c4)
|
||||
- swap tiles by dragging their header (97af85c8)
|
||||
- tickets + plan day button, fallback list and prime action (e35b054a)
|
||||
- add RunTicketImportNow and ticket fallback list endpoints (9fc2e26e)
|
||||
- ImportTickets runs the ticket import before filling MyDay (915f2c83)
|
||||
- add get_ticket_import_candidates and import_ticket tools (57c30c4a)
|
||||
- sweep all project boards for assigned tickets and tasks (47583ab1)
|
||||
- add ImportTickets prime kind, ticket fallback list and import prompt (a0b234fe)
|
||||
|
||||
### Fixes
|
||||
|
||||
- pass the stash consent to the conflict resolver so it opens on a dirty target (feb0afa3)
|
||||
- auto-scroll the lists sidebar while dragging near its top/bottom edge (bba7117d)
|
||||
- close the pseudo console when a conpty tile is disposed (a75acbb2)
|
||||
- remove remote mcp config via trap when the tile kills ssh (b35755e1)
|
||||
- put ~/.local/bin on PATH for remote claude in ssh sessions (4b1f5c65)
|
||||
- ssh settings tab survives an offline worker without wiping workspaces (8c5db95c)
|
||||
- stop pinning --effort on hand-driven interactive sessions (d9f42d7d)
|
||||
- drop outbound HttpClient request logs from the worker log (542501cb)
|
||||
- wrap table headers, ellipsis-trim long cells with tooltip (b7e7aebc)
|
||||
- offer clean reset for parked tasks in the Git tab (9a9b86fd)
|
||||
- detect external merges, clean up empty dirs, prune in owning repo (0c0bb7f2)
|
||||
- Log Visualizer shows every footer-log message (c956ab10)
|
||||
- align island header separators via shared header height (34c71bbe)
|
||||
- show post-merge verify as Verifying instead of In review (46e9303d)
|
||||
- open diff viewer and merge window as independent top-level windows (0fea339b)
|
||||
- reset cursor to arrow over pane header buttons (74e06d62)
|
||||
- preview_merge_set no longer reports a symbol the same diff re-declares (06fb73c4)
|
||||
- batch_get_tasks accept "number" and "id" in fields filter (ef5dad27)
|
||||
- Mission Control no longer lists finished operations as running (e9c1ff2e)
|
||||
- drop SignalR hub invocation events so PATs stay out of the worker log (61645acf)
|
||||
- strip credentials from origin URL in the not-ready message (c834f17c)
|
||||
- kept worktree in a company repo opens a PR, unexpected ADO/git errors never fail a task start (4917533c)
|
||||
- keep Git Credential Manager from prompting during company fetch/push (674db27f)
|
||||
- open the merge modal non-modal so a running merge no longer blocks the main window (3d74e3da)
|
||||
- hide Submit for review on list-handler panes in Mission Control (0cfc28ad)
|
||||
- prevent dragging completed tasks in the task list (b776fd28)
|
||||
- add right padding to add-task input so the caret clears the last glyph (335bfe95)
|
||||
- block approve of a planning parent with an unfinished child, surface git stderr on ff failure (acd188bc)
|
||||
- return to the list base branch after integration cleanup, not a guessed task branch (cd5b27c0)
|
||||
- deliver AltGr characters to ConPTY tiles as text (cf3db919)
|
||||
- register ticket import tools in the external MCP container (2b40ea2d)
|
||||
|
||||
### Performance
|
||||
|
||||
- read untracked-worktree sweep from .git instead of spawning git per list (59d990c2)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- drop ForceSetStatusAsync, route every status write through a guarded transition (ff7197c4)
|
||||
- delete dead Icon.* geometries and icon-stroke styles, add MDI name guard test (de685151)
|
||||
- Material icons for list, folder and smart-list icon keys with legacy mapping (7c0fe92a)
|
||||
- drop harness duplicates and maintainer notes from bundled prompts (a76788a5)
|
||||
- detect untracked worktrees by path instead of branch prefix (8911408b)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document ssh workspace sessions (2e29e03a)
|
||||
- spec and plan for ssh workspace sessions (f7625baa)
|
||||
- document company mode (fetch, push, draft PR) and its open smoke tests (93fd6740)
|
||||
- plan for unit branch part 3 (company mode, draft PR) (2b2f61d7)
|
||||
- describe unit-branch routing in the review_task tool description (a3538973)
|
||||
- document unit branch, child absorb and legacy approve routing (82336848)
|
||||
- plan for unit branch part 2 (children fast-forward) (dc73f138)
|
||||
- document new branch naming and per-list base branch (8ac272df)
|
||||
- unit branch part 1 - naming and per-list base branch (db3c7e69)
|
||||
- per-list base branch and fetch before unit branch (3dc735b2)
|
||||
- unit branch and draft PR design (0e9373c0)
|
||||
- note the external MCP DI registration trap (16adcad8)
|
||||
- document cross-project ticket import and open checks (7724f5eb)
|
||||
- note the ImportTickets prime radio and the ticket fallback list (cb775958)
|
||||
- implementation plan for cross-project ticket import (c34680b4)
|
||||
- switch import source to project-board sweep after live probe (5bb49495)
|
||||
- design for cross-project ticket import with day planning (0ac49653)
|
||||
- update for v2.18.0 (899648ad)
|
||||
|
||||
## v2.18.0 — 2026-09-30
|
||||
|
||||
### Features
|
||||
|
||||
@@ -11,7 +11,7 @@ process hosts (real `claude` TUI in a Mission Control tile).
|
||||
Autonomous queue tasks are **not** covered here — they stay on the stream-json path via
|
||||
`TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
|
||||
|
||||
## The five session kinds
|
||||
## The six session kinds
|
||||
|
||||
| Kind | Hub spec method | Notes |
|
||||
|---|---|---|
|
||||
@@ -20,6 +20,7 @@ Autonomous queue tasks are **not** covered here — they stay on the stream-json
|
||||
| Planning | (planning start/resume) | No `--effort`; uses `--permission-mode default`, **not** `plan` |
|
||||
| List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) |
|
||||
| SSH workspace | `GetSshLaunchSpec` | Exe is `ssh.exe`, not claude: `-R <random port>:127.0.0.1:<remote_mcp_port>` + one remote shell command that base64-decodes a `claudedo` MCP config (per-session token) to `~/.claudedo/mcp-<paneId>.json` and runs claude. **Not** recorded in `mission_control_sessions`, no `claudedo_pane`/handoff hint. Workspaces: `app_settings.ssh_workspaces`, Settings → SSH-Workspaces |
|
||||
| Custom tile | none — built in the UI | `MissionControlViewModel.OpenCustomTileSession`: any console program from `ui.config.json` → `CustomTiles` (Settings → Custom tiles). Exe resolved via `ExecutableResolver` (shims through `cmd /c`), args split by `CustomTile.SplitArgs` (the library re-quotes each token). Empty folder → a submenu of home/list folders/browse. Not recorded, no env, no MCP |
|
||||
|
||||
## ⚠️ Gotcha: never pass task free-text as a CLI argument
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Checkliste lebt in `docs/verification-handoff.md`.
|
||||
|
||||
## UX / Nits (offen)
|
||||
|
||||
- **Eigene Tiles — Sichtprüfung offen** (gebaut 2026-10-05, `f37b59d5`). Nur Unit-Tests, nicht in der laufenden App geklickt. Prüfen: Einstellungen → „Eigene Tiles" anlegen/speichern; im +-Flyout öffnet ein Eintrag mit Ordner direkt ein Tile; ein Eintrag ohne Ordner zeigt ein **Untermenü** (Home, Listen-Ordner, „Ordner wählen…") — wackligster Teil ist der selbstreferenzierende `SessionTargetItemTheme` (`DynamicResource`) in `MissionControlView.axaml`; ungültiges Programm zeigt die Fehlermeldung im Tile.
|
||||
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
|
||||
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
|
||||
- **Modal-Bodies unten abgeschnitten — gefixt, Sichtprüfung offen** (Ursache 2026-09-02 verifiziert: `ScrollViewer.Padding` zählt in Avalonia 12 nicht zum Extent; Fix ist das Padding auf ein `Margin` des inneren Inhalts umzuziehen). Betraf `AboutModalView`, `MergeHelperSelectionModal`, `MergeModalView`, `RepoImportModalView`, `WorktreesOverviewModalView` — alle fünf umgestellt. `ListSettingsModalView`, `LogVisualizerView` und `WorkConsole` hatten zu diesem Zeitpunkt bereits kein ScrollViewer-Padding mehr.
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Berichte",
|
||||
"categoryTickets": "Externe Dienste",
|
||||
"categorySsh": "SSH-Workspaces"
|
||||
"categorySsh": "SSH-Workspaces",
|
||||
"categoryCustomTiles": "Eigene Tiles"
|
||||
},
|
||||
"repoHint": {
|
||||
"text": "Wirkt erst mit verknüpftem Repo.",
|
||||
@@ -176,6 +177,20 @@
|
||||
"path": "SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten."
|
||||
}
|
||||
},
|
||||
"customTiles": {
|
||||
"hint": "Konsolenprogramme, die Mission Control als Tile öffnen kann (z. B. pwsh, ssh lenovo-server, lazygit). Argumente werden an Leerzeichen getrennt; ein Argument mit Leerzeichen in Anführungszeichen setzen. Ordner leer lassen, um ihn beim Start auszuwählen.",
|
||||
"name": "Name",
|
||||
"program": "Programm",
|
||||
"arguments": "Argumente",
|
||||
"folder": "Ordner (optional)",
|
||||
"pickFolder": "Ordner wählen",
|
||||
"add": "Tile hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"invalid": {
|
||||
"name": "Eigenes Tile: Name fehlt.",
|
||||
"program": "Eigenes Tile „{0}“: Programm fehlt."
|
||||
}
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "geerbt · Liste",
|
||||
"inheritedFromGlobal": "geerbt · Global",
|
||||
@@ -414,6 +429,8 @@
|
||||
"openSession": "Sitzung öffnen",
|
||||
"homeFolder": "Home (Schnellsitzung)",
|
||||
"sshTarget": "{0} (SSH)",
|
||||
"customTarget": "{0} (eigenes Tile)",
|
||||
"customProgramNotFound": "Programm „{0}“ nicht gefunden — Namen prüfen oder vollen Pfad angeben (Einstellungen → Eigene Tiles).",
|
||||
"browseForFolder": "Ordner wählen…",
|
||||
"recentSessions": "Letzte Sitzungen",
|
||||
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Reports",
|
||||
"categoryTickets": "External services",
|
||||
"categorySsh": "SSH workspaces"
|
||||
"categorySsh": "SSH workspaces",
|
||||
"categoryCustomTiles": "Custom tiles"
|
||||
},
|
||||
"repoHint": {
|
||||
"text": "Only takes effect once a repo is linked.",
|
||||
@@ -176,6 +177,20 @@
|
||||
"path": "SSH workspace \"{0}\": remote path must start with / or ~ and must not contain '."
|
||||
}
|
||||
},
|
||||
"customTiles": {
|
||||
"hint": "Console programs Mission Control can open as a tile (e.g. pwsh, ssh lenovo-server, lazygit). Arguments are split on spaces; quote an argument that contains one. Leave the folder empty to pick it when starting the tile.",
|
||||
"name": "Name",
|
||||
"program": "Program",
|
||||
"arguments": "Arguments",
|
||||
"folder": "Folder (optional)",
|
||||
"pickFolder": "Choose folder",
|
||||
"add": "Add tile",
|
||||
"remove": "Remove",
|
||||
"invalid": {
|
||||
"name": "Custom tile: name is missing.",
|
||||
"program": "Custom tile \"{0}\": program is missing."
|
||||
}
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "inherited · List",
|
||||
"inheritedFromGlobal": "inherited · Global",
|
||||
@@ -414,6 +429,8 @@
|
||||
"openSession": "Open session",
|
||||
"homeFolder": "Home (quick session)",
|
||||
"sshTarget": "{0} (SSH)",
|
||||
"customTarget": "{0} (custom)",
|
||||
"customProgramNotFound": "Program \"{0}\" not found — check the name or use a full path (Settings → Custom tiles).",
|
||||
"browseForFolder": "Choose folder…",
|
||||
"recentSessions": "Recent sessions",
|
||||
"recentSessionsEmpty": "No sessions in the last 14 days",
|
||||
|
||||
@@ -19,6 +19,9 @@ public sealed class AppSettings
|
||||
/// <summary>Mission Control overview column count; 0 = auto (derived from the pane count).</summary>
|
||||
public int MissionControlColumns { get; set; }
|
||||
|
||||
/// <summary>User-defined console programs offered in Mission Control's new-session flyout.</summary>
|
||||
public List<CustomTile> CustomTiles { get; set; } = new();
|
||||
|
||||
private static readonly string DefaultConfigPath = Paths.Expand("~/.claudeDo/ui.config.json");
|
||||
|
||||
/// Where this instance persists. Instance-level (not static) so tests can redirect it —
|
||||
@@ -58,3 +61,35 @@ public sealed class AppSettings
|
||||
File.WriteAllText(ConfigPath, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A console program the user can open as a Mission Control tile. An empty
|
||||
/// <see cref="Folder"/> means the folder is picked at launch.</summary>
|
||||
public sealed class CustomTile
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Program { get; set; } = "";
|
||||
public string Arguments { get; set; } = "";
|
||||
public string Folder { get; set; } = "";
|
||||
|
||||
/// <summary>Splits an argument string into tokens: whitespace separates, double quotes group and
|
||||
/// are dropped. The terminal control quotes every token itself, so the raw string can't pass through.</summary>
|
||||
public static List<string> SplitArgs(string? args)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
bool inQuotes = false, hasToken = false;
|
||||
foreach (var c in args ?? "")
|
||||
{
|
||||
if (c == '"') { inQuotes = !inQuotes; hasToken = true; }
|
||||
else if (char.IsWhiteSpace(c) && !inQuotes)
|
||||
{
|
||||
if (hasToken) tokens.Add(current.ToString());
|
||||
current.Clear();
|
||||
hasToken = false;
|
||||
}
|
||||
else { current.Append(c); hasToken = true; }
|
||||
}
|
||||
if (hasToken) tokens.Add(current.ToString());
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +512,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();
|
||||
|
||||
var showListChip = list.Kind == ListKind.Virtual;
|
||||
// Smart (My Day, Important, Planned) and virtual lists span many lists — show the origin.
|
||||
var showListChip = list.Kind != ListKind.User;
|
||||
foreach (var t in filteredList)
|
||||
{
|
||||
TaskRowViewModel row;
|
||||
|
||||
@@ -98,8 +98,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
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; }
|
||||
// MissionControlView.PickFolderAsync). Returns the chosen path, or null when cancelled.
|
||||
public Func<System.Threading.Tasks.Task<string?>>? PickFolderAsync { get; set; }
|
||||
|
||||
// View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the
|
||||
// view the same way TasksIslandViewModel/DetailsIslandViewModel are.
|
||||
@@ -274,15 +274,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// Home + browse go in first so the menu stays usable even if the list query below fails.
|
||||
// Home is the target for quick general questions that belong to no project.
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var folders = new List<(string Label, string Dir)> { (Loc.T("missionControl.homeFolder"), home) };
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.homeFolder"),
|
||||
Label = folders[0].Label,
|
||||
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)),
|
||||
});
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.browseForFolder"),
|
||||
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()),
|
||||
StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(OpenAdHocConPtySessionAsync)),
|
||||
});
|
||||
try
|
||||
{
|
||||
@@ -296,6 +297,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
foreach (var l in lists)
|
||||
{
|
||||
var dir = l.WorkingDir!;
|
||||
folders.Add((l.Name, dir));
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = l.Name,
|
||||
@@ -314,6 +316,65 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
|
||||
|
||||
// Outside the try: custom tiles come from ui.config.json and still work without the DB.
|
||||
foreach (var tile in _settings?.CustomTiles ?? [])
|
||||
{
|
||||
var t = tile;
|
||||
var label = Loc.T("missionControl.customTarget", t.Name);
|
||||
if (!string.IsNullOrWhiteSpace(t.Folder))
|
||||
{
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = label,
|
||||
StartCommand = new RelayCommand(() => OpenCustomTileSession(t, t.Folder)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var children = folders
|
||||
.Select(f => new SessionTargetViewModel
|
||||
{
|
||||
Label = f.Label,
|
||||
StartCommand = new RelayCommand(() => OpenCustomTileSession(t, f.Dir)),
|
||||
})
|
||||
.Append(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.browseForFolder"),
|
||||
StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(dir =>
|
||||
{
|
||||
OpenCustomTileSession(t, dir);
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
})),
|
||||
})
|
||||
.ToList();
|
||||
SessionTargets.Add(new SessionTargetViewModel { Label = label, Children = children });
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task BrowseAndOpenAsync(Func<string, System.Threading.Tasks.Task> open)
|
||||
{
|
||||
if (PickFolderAsync is null) return;
|
||||
if (await PickFolderAsync() is { Length: > 0 } dir) await open(dir);
|
||||
}
|
||||
|
||||
/// <summary>Opens a user-defined program from ui.config.json as a tile. Never deduped, never
|
||||
/// recorded for resume — the UI builds the launch itself, the worker is not involved.</summary>
|
||||
internal void OpenCustomTileSession(CustomTile tile, string directory)
|
||||
{
|
||||
var paneId = Guid.NewGuid().ToString("n");
|
||||
AddConPtyPane(new ConPtyPaneViewModel(null, tile.Name, () =>
|
||||
{
|
||||
// Throws into the pane's start-failure line, same as a worker-side launch error.
|
||||
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(tile.Program.Trim())
|
||||
?? throw new InvalidOperationException(Loc.T("missionControl.customProgramNotFound", tile.Program));
|
||||
var args = CustomTile.SplitArgs(tile.Arguments);
|
||||
// .cmd/.bat shims can't be the process image of a pseudo console; run them through cmd.
|
||||
var (exe, argv) = resolved.IsShim
|
||||
? ("cmd.exe", (IReadOnlyList<string>)new[] { "/c", resolved.Path }.Concat(args).ToList())
|
||||
: (resolved.Path, args);
|
||||
return System.Threading.Tasks.Task.FromResult(new TerminalLaunchDescriptor(
|
||||
directory, exe, argv, new Dictionary<string, string>()));
|
||||
}) { IdentityKey = paneId });
|
||||
}
|
||||
|
||||
/// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on
|
||||
@@ -1013,9 +1074,11 @@ public sealed partial class RecentSessionViewModel : ObservableObject
|
||||
public bool HasBrief => ShowBriefCommand is not null;
|
||||
}
|
||||
|
||||
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
|
||||
/// <summary>One entry in the "new session" menu: a folder to start in, browse, an SSH workspace, or a
|
||||
/// custom tile — which, without a fixed folder, opens a submenu of folders instead of starting.</summary>
|
||||
public sealed class SessionTargetViewModel
|
||||
{
|
||||
public required string Label { get; init; }
|
||||
public required IRelayCommand StartCommand { get; init; }
|
||||
public IRelayCommand? StartCommand { get; init; }
|
||||
public IReadOnlyList<SessionTargetViewModel>? Children { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
/// <summary>Settings tab for user-defined programs offered as Mission Control tiles. Persists to
|
||||
/// ui.config.json (a UI-only preference), so it works without the worker.</summary>
|
||||
public sealed partial class CustomTilesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly AppSettings _settings;
|
||||
|
||||
/// <summary>Configured tiles, edited in place.</summary>
|
||||
public ObservableCollection<CustomTileRowViewModel> Tiles { get; } = new();
|
||||
|
||||
public CustomTilesSettingsTabViewModel(AppSettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
foreach (var t in settings.CustomTiles)
|
||||
Tiles.Add(new CustomTileRowViewModel(Remove) { Name = t.Name, Program = t.Program, Arguments = t.Arguments, Folder = t.Folder });
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Add() => Tiles.Add(new CustomTileRowViewModel(Remove));
|
||||
|
||||
private void Remove(CustomTileRowViewModel row) => Tiles.Remove(row);
|
||||
|
||||
/// <summary>Localized error for the first row missing its name or program, or null.</summary>
|
||||
public string? Validate()
|
||||
{
|
||||
foreach (var row in Tiles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Name)) return Loc.T("settings.customTiles.invalid.name");
|
||||
if (string.IsNullOrWhiteSpace(row.Program)) return Loc.T("settings.customTiles.invalid.program", row.Name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
_settings.CustomTiles = Tiles.Select(r => r.ToModel()).ToList();
|
||||
_settings.Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One editable custom tile row.</summary>
|
||||
public sealed partial class CustomTileRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly Action<CustomTileRowViewModel> _remove;
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _program = "";
|
||||
[ObservableProperty] private string _arguments = "";
|
||||
|
||||
/// <summary>Start folder; empty = picked from a submenu at launch.</summary>
|
||||
[ObservableProperty] private string _folder = "";
|
||||
|
||||
public CustomTileRowViewModel(Action<CustomTileRowViewModel> remove) => _remove = remove;
|
||||
|
||||
[RelayCommand]
|
||||
private void Remove() => _remove(this);
|
||||
|
||||
/// <summary>Snapshot as the persisted model, trimmed.</summary>
|
||||
public CustomTile ToModel() => new()
|
||||
{
|
||||
Name = Name.Trim(), Program = Program.Trim(), Arguments = Arguments.Trim(), Folder = Folder.Trim(),
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
|
||||
public TicketSettingsTabViewModel Tickets { get; }
|
||||
public SshSettingsTabViewModel Ssh { get; }
|
||||
public CustomTilesSettingsTabViewModel CustomTiles { get; }
|
||||
|
||||
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
|
||||
public bool ShowOnlineInbox => false;
|
||||
@@ -111,6 +112,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
|
||||
Tickets = new TicketSettingsTabViewModel(worker, dbFactory);
|
||||
Ssh = new SshSettingsTabViewModel(worker);
|
||||
CustomTiles = new CustomTilesSettingsTabViewModel(appSettings);
|
||||
|
||||
_categories = new List<SettingsCategory>
|
||||
{
|
||||
@@ -124,6 +126,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
new("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
|
||||
new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", TicketSettingsTabViewModel.FeatureEnabled, false),
|
||||
new("Ssh", new LocalizedString(localizer, "settings.sidebar.categorySsh"), "Erweitert", true, false),
|
||||
new("CustomTiles", new LocalizedString(localizer, "settings.sidebar.categoryCustomTiles"), "Erweitert", true, false),
|
||||
};
|
||||
BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
|
||||
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
|
||||
@@ -192,9 +195,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private async Task Save()
|
||||
{
|
||||
var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate() ?? Ssh.Validate();
|
||||
var err = General.Validate() ?? Worktrees.Validate() ?? Prime.Validate() ?? Ssh.Validate() ?? CustomTiles.Validate();
|
||||
if (err is not null) { ValidationError = err; return; }
|
||||
ValidationError = "";
|
||||
// ui.config.json, not the worker — saved first so an offline worker can't lose the edit.
|
||||
CustomTiles.Save();
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
<mc:ConPtyPaneHost Margin="6" />
|
||||
</DataTemplate>
|
||||
</UserControl.DataTemplates>
|
||||
<UserControl.Resources>
|
||||
<!-- Keyed so a custom tile's folder submenu reuses it for its own items (one level deep). -->
|
||||
<ControlTheme x:Key="SessionTargetItemTheme" TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
|
||||
x:DataType="vm:SessionTargetViewModel">
|
||||
<Setter Property="Header" Value="{Binding Label}"/>
|
||||
<Setter Property="Command" Value="{Binding StartCommand}"/>
|
||||
<Setter Property="ItemsSource" Value="{Binding Children}"/>
|
||||
<Setter Property="ItemContainerTheme" Value="{DynamicResource SessionTargetItemTheme}"/>
|
||||
</ControlTheme>
|
||||
</UserControl.Resources>
|
||||
<UserControl.Styles>
|
||||
<!-- Drop target outline while dragging a tile by its header; matches the pane's Margin/CornerRadius. -->
|
||||
<Style Selector="Border.pane-drop-hint">
|
||||
@@ -47,15 +57,8 @@
|
||||
ToolTip.Tip="{loc:Tr missionControl.newSession}">
|
||||
<PathIcon Width="16" Height="16" Data="{Binding Source='mdi:Plus', Converter={StaticResource IconKey}}"/>
|
||||
<Button.Flyout>
|
||||
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening">
|
||||
<MenuFlyout.ItemContainerTheme>
|
||||
<ControlTheme TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
|
||||
x:DataType="vm:SessionTargetViewModel">
|
||||
<Setter Property="Header" Value="{Binding Label}"/>
|
||||
<Setter Property="Command" Value="{Binding StartCommand}"/>
|
||||
</ControlTheme>
|
||||
</MenuFlyout.ItemContainerTheme>
|
||||
</MenuFlyout>
|
||||
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening"
|
||||
ItemContainerTheme="{StaticResource SessionTargetItemTheme}"/>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<Button x:Name="RecentSessionsButton" Classes="icon-btn"
|
||||
|
||||
@@ -149,21 +149,18 @@ public partial class MissionControlView : UserControl
|
||||
|
||||
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()
|
||||
// The view owns the folder picker (it needs a TopLevel); the VM decides what to open with the path.
|
||||
private async System.Threading.Tasks.Task<string?> PickFolderAsync()
|
||||
{
|
||||
if (DataContext is not MissionControlViewModel vm) return;
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null) return;
|
||||
if (topLevel is null) return null;
|
||||
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose a directory",
|
||||
AllowMultiple = false,
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
await vm.OpenAdHocConPtySessionAsync(folders[0].Path.LocalPath);
|
||||
return folders.Count == 0 ? null : folders[0].Path.LocalPath;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
@@ -172,7 +169,7 @@ public partial class MissionControlView : UserControl
|
||||
{
|
||||
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
||||
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
_vm.BrowseForSessionRequested = null;
|
||||
_vm.PickFolderAsync = null;
|
||||
_vm.ConfirmAsync = null;
|
||||
}
|
||||
|
||||
@@ -182,7 +179,7 @@ public partial class MissionControlView : UserControl
|
||||
{
|
||||
_vm.Panes.CollectionChanged += OnPanesChanged;
|
||||
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
|
||||
_vm.PickFolderAsync = PickFolderAsync;
|
||||
_vm.ConfirmAsync = ShowConfirmAsync;
|
||||
}
|
||||
|
||||
|
||||
@@ -852,6 +852,42 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.sidebar.categoryCustomTiles}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr settings.customTiles.hint}" TextWrapping="Wrap" Opacity="0.6"/>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding CustomTiles.Tiles}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="settings:CustomTileRowViewModel">
|
||||
<Grid ColumnDefinitions="120,120,*,*,Auto,Auto" ColumnSpacing="8" Margin="0,0,0,6">
|
||||
<TextBox Grid.Column="0" Text="{Binding Name, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.customTiles.name}"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding Program, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.customTiles.program}"/>
|
||||
<TextBox Grid.Column="2" Text="{Binding Arguments, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.customTiles.arguments}"/>
|
||||
<TextBox Grid.Column="3" Text="{Binding Folder, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.customTiles.folder}"/>
|
||||
<Button Grid.Column="4" Classes="icon-btn" Click="OnPickCustomTileFolder"
|
||||
ToolTip.Tip="{loc:Tr settings.customTiles.pickFolder}">
|
||||
<PathIcon Width="14" Height="14" Data="{Binding Source='mdi:FolderOpenOutline', Converter={StaticResource IconKey}}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="5" Classes="icon-btn" Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="{loc:Tr settings.customTiles.remove}">
|
||||
<PathIcon Width="14" Height="14" Data="{Binding Source='mdi:TrashCanOutline', Converter={StaticResource IconKey}}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="btn" HorizontalAlignment="Left" Command="{Binding CustomTiles.AddCommand}"
|
||||
Content="{loc:Tr settings.customTiles.add}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
@@ -8,7 +11,7 @@ public partial class SettingsModalView : Window
|
||||
public SettingsModalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
@@ -17,4 +20,11 @@ public partial class SettingsModalView : Window
|
||||
if (DataContext is SettingsModalViewModel vm)
|
||||
vm.CloseAction = Close;
|
||||
}
|
||||
|
||||
private async void OnPickCustomTileFolder(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as Control)?.DataContext is not CustomTileRowViewModel row) return;
|
||||
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { AllowMultiple = false });
|
||||
if (folders.Count > 0) row.Folder = folders[0].Path.LocalPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Assert.Equal(4, vm.SessionTargets.Count); // home, browse, Alpha, VPS
|
||||
Assert.Contains("VPS", vm.SessionTargets[3].Label);
|
||||
|
||||
vm.SessionTargets[3].StartCommand.Execute(null);
|
||||
vm.SessionTargets[3].StartCommand!.Execute(null);
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
Assert.Equal("VPS", pane.DisplayTitle);
|
||||
}
|
||||
@@ -339,7 +339,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
|
||||
using var vm = BuildVm(new FakeWorker());
|
||||
var browsed = false;
|
||||
vm.BrowseForSessionRequested = () => browsed = true;
|
||||
vm.PickFolderAsync = () => { browsed = true; return Task.FromResult<string?>(null); };
|
||||
|
||||
await vm.RefreshSessionTargetsAsync();
|
||||
|
||||
@@ -347,10 +347,65 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Assert.Equal(4, vm.SessionTargets.Count);
|
||||
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(2).Select(t => t.Label).ToArray());
|
||||
|
||||
vm.SessionTargets[1].StartCommand.Execute(null);
|
||||
vm.SessionTargets[1].StartCommand!.Execute(null);
|
||||
Assert.True(browsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSessionTargetsAsync_CustomTiles_FixedFolderStartsDirectly_EmptyFolderOffersFolderSubmenu()
|
||||
{
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var settings = new AppSettings { ConfigPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n") + ".json") };
|
||||
settings.CustomTiles.Add(new CustomTile { Name = "Shell", Program = "cmd", Folder = @"C:\" });
|
||||
settings.CustomTiles.Add(new CustomTile { Name = "Git", Program = "cmd" });
|
||||
var worker = new FakeWorker();
|
||||
using var vm = new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker), settings);
|
||||
|
||||
await vm.RefreshSessionTargetsAsync();
|
||||
|
||||
// Home, browse, Alpha, then the two custom tiles.
|
||||
var fixedTile = vm.SessionTargets[3];
|
||||
var askTile = vm.SessionTargets[4];
|
||||
Assert.NotNull(fixedTile.StartCommand);
|
||||
Assert.Null(fixedTile.Children);
|
||||
Assert.Null(askTile.StartCommand);
|
||||
// Same folder choice as the top level: home, the list folders, then browse.
|
||||
Assert.Equal(3, askTile.Children!.Count);
|
||||
Assert.Equal("Alpha", askTile.Children[1].Label);
|
||||
|
||||
askTile.Children[1].StartCommand!.Execute(null);
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
Assert.Equal("Git", pane.DisplayTitle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CustomTile_SplitArgs_SplitsOnWhitespace_QuotesGroup()
|
||||
{
|
||||
Assert.Equal(new[] { "lenovo-server", "-p", "56789" }, CustomTile.SplitArgs("lenovo-server -p 56789 "));
|
||||
Assert.Equal(new[] { "-c", "git log --oneline", "" }, CustomTile.SplitArgs("-c \"git log --oneline\" \"\""));
|
||||
Assert.Empty(CustomTile.SplitArgs(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettings_CustomTiles_RoundTripThroughConfigFile()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n") + ".json");
|
||||
try
|
||||
{
|
||||
var settings = new AppSettings { ConfigPath = path };
|
||||
settings.CustomTiles.Add(new CustomTile { Name = "Server", Program = "ssh", Arguments = "lenovo-server" });
|
||||
settings.Save();
|
||||
|
||||
var tile = Assert.Single(AppSettings.Load(path).CustomTiles);
|
||||
Assert.Equal(("Server", "ssh", "lenovo-server", ""), (tile.Name, tile.Program, tile.Arguments, tile.Folder));
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
private async Task SeedQueueAsync()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
|
||||
Reference in New Issue
Block a user