Compare commits

...
4 Commits
Author SHA1 Message Date
mika kuns 5f27fc3049 feat(ui): show the origin list chip in smart lists 2026-10-21 14:57:20 +02:00
CubeGameLPandClaude Opus 5.5 23ed2b4543 docs(open): visual check for custom tiles
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:09:29 +02:00
CubeGameLPandClaude Opus 5.5 f37b59d58f feat(mission-control): custom tiles — open any console program as a tile
Programs are configured in Settings → Custom tiles and stored in ui.config.json.
A tile with a fixed folder starts on click; without one it offers a submenu of
home, the list folders and browse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:09:19 +02:00
ClaudeDo CI 43b03e4837 docs(changelog): update for v2.19.0 2026-10-02 17:13:48 +00:00
15 changed files with 455 additions and 33 deletions
+112
View File
@@ -1,5 +1,117 @@
# Changelog # 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 ## v2.18.0 — 2026-09-30
### Features ### Features
+2 -1
View File
@@ -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 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). `TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
## The five session kinds ## The six session kinds
| Kind | Hub spec method | Notes | | 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` | | 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) | | 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 | | 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 ## ⚠️ Gotcha: never pass task free-text as a CLI argument
+1
View File
@@ -13,6 +13,7 @@ Checkliste lebt in `docs/verification-handoff.md`.
## UX / Nits (offen) ## 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".) - **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>"). - **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. - **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.
+18 -1
View File
@@ -17,7 +17,8 @@
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Berichte", "categoryReports": "Berichte",
"categoryTickets": "Externe Dienste", "categoryTickets": "Externe Dienste",
"categorySsh": "SSH-Workspaces" "categorySsh": "SSH-Workspaces",
"categoryCustomTiles": "Eigene Tiles"
}, },
"repoHint": { "repoHint": {
"text": "Wirkt erst mit verknüpftem Repo.", "text": "Wirkt erst mit verknüpftem Repo.",
@@ -176,6 +177,20 @@
"path": "SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten." "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": { "inherit": {
"inheritedFromList": "geerbt · Liste", "inheritedFromList": "geerbt · Liste",
"inheritedFromGlobal": "geerbt · Global", "inheritedFromGlobal": "geerbt · Global",
@@ -414,6 +429,8 @@
"openSession": "Sitzung öffnen", "openSession": "Sitzung öffnen",
"homeFolder": "Home (Schnellsitzung)", "homeFolder": "Home (Schnellsitzung)",
"sshTarget": "{0} (SSH)", "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…", "browseForFolder": "Ordner wählen…",
"recentSessions": "Letzte Sitzungen", "recentSessions": "Letzte Sitzungen",
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen", "recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
+18 -1
View File
@@ -17,7 +17,8 @@
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
"categoryReports": "Reports", "categoryReports": "Reports",
"categoryTickets": "External services", "categoryTickets": "External services",
"categorySsh": "SSH workspaces" "categorySsh": "SSH workspaces",
"categoryCustomTiles": "Custom tiles"
}, },
"repoHint": { "repoHint": {
"text": "Only takes effect once a repo is linked.", "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 '." "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": { "inherit": {
"inheritedFromList": "inherited · List", "inheritedFromList": "inherited · List",
"inheritedFromGlobal": "inherited · Global", "inheritedFromGlobal": "inherited · Global",
@@ -414,6 +429,8 @@
"openSession": "Open session", "openSession": "Open session",
"homeFolder": "Home (quick session)", "homeFolder": "Home (quick session)",
"sshTarget": "{0} (SSH)", "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…", "browseForFolder": "Choose folder…",
"recentSessions": "Recent sessions", "recentSessions": "Recent sessions",
"recentSessionsEmpty": "No sessions in the last 14 days", "recentSessionsEmpty": "No sessions in the last 14 days",
+35
View File
@@ -19,6 +19,9 @@ public sealed class AppSettings
/// <summary>Mission Control overview column count; 0 = auto (derived from the pane count).</summary> /// <summary>Mission Control overview column count; 0 = auto (derived from the pane count).</summary>
public int MissionControlColumns { get; set; } 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"); private static readonly string DefaultConfigPath = Paths.Expand("~/.claudeDo/ui.config.json");
/// Where this instance persists. Instance-level (not static) so tests can redirect it — /// 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); 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(); 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) foreach (var t in filteredList)
{ {
TaskRowViewModel row; TaskRowViewModel row;
@@ -98,8 +98,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public bool HasRecentSessions => RecentSessions.Count > 0; public bool HasRecentSessions => RecentSessions.Count > 0;
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see // View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked). // MissionControlView.PickFolderAsync). Returns the chosen path, or null when cancelled.
public Action? BrowseForSessionRequested { get; set; } 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-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the
// view the same way TasksIslandViewModel/DetailsIslandViewModel are. // 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 + 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. // Home is the target for quick general questions that belong to no project.
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var folders = new List<(string Label, string Dir)> { (Loc.T("missionControl.homeFolder"), home) };
SessionTargets.Add(new SessionTargetViewModel SessionTargets.Add(new SessionTargetViewModel
{ {
Label = Loc.T("missionControl.homeFolder"), Label = folders[0].Label,
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)), StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)),
}); });
SessionTargets.Add(new SessionTargetViewModel SessionTargets.Add(new SessionTargetViewModel
{ {
Label = Loc.T("missionControl.browseForFolder"), Label = Loc.T("missionControl.browseForFolder"),
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()), StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(OpenAdHocConPtySessionAsync)),
}); });
try try
{ {
@@ -296,6 +297,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
foreach (var l in lists) foreach (var l in lists)
{ {
var dir = l.WorkingDir!; var dir = l.WorkingDir!;
folders.Add((l.Name, dir));
SessionTargets.Add(new SessionTargetViewModel SessionTargets.Add(new SessionTargetViewModel
{ {
Label = l.Name, 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)); } 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 /// <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; 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 sealed class SessionTargetViewModel
{ {
public required string Label { get; init; } 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 SessionSkillsSettingsTabViewModel SessionSkills { get; }
public TicketSettingsTabViewModel Tickets { get; } public TicketSettingsTabViewModel Tickets { get; }
public SshSettingsTabViewModel Ssh { get; } public SshSettingsTabViewModel Ssh { get; }
public CustomTilesSettingsTabViewModel CustomTiles { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung. // Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false; public bool ShowOnlineInbox => false;
@@ -111,6 +112,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
SessionSkills = new SessionSkillsSettingsTabViewModel(worker); SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
Tickets = new TicketSettingsTabViewModel(worker, dbFactory); Tickets = new TicketSettingsTabViewModel(worker, dbFactory);
Ssh = new SshSettingsTabViewModel(worker); Ssh = new SshSettingsTabViewModel(worker);
CustomTiles = new CustomTilesSettingsTabViewModel(appSettings);
_categories = new List<SettingsCategory> _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("OnlineInbox", new LocalizedString(localizer, "settings.onlineInbox.tabHeader"), "Erweitert", ShowOnlineInbox, false),
new("Tickets", new LocalizedString(localizer, "settings.sidebar.categoryTickets"), "Erweitert", TicketSettingsTabViewModel.FeatureEnabled, 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("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(); BasisCategories = _categories.Where(c => c.Group == "Basis").ToList();
ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList(); ErweitertCategories = _categories.Where(c => c.Group == "Erweitert").ToList();
@@ -192,9 +195,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private async Task Save() 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; } if (err is not null) { ValidationError = err; return; }
ValidationError = ""; ValidationError = "";
// ui.config.json, not the worker — saved first so an offline worker can't lose the edit.
CustomTiles.Save();
IsBusy = true; IsBusy = true;
try try
@@ -16,6 +16,16 @@
<mc:ConPtyPaneHost Margin="6" /> <mc:ConPtyPaneHost Margin="6" />
</DataTemplate> </DataTemplate>
</UserControl.DataTemplates> </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> <UserControl.Styles>
<!-- Drop target outline while dragging a tile by its header; matches the pane's Margin/CornerRadius. --> <!-- Drop target outline while dragging a tile by its header; matches the pane's Margin/CornerRadius. -->
<Style Selector="Border.pane-drop-hint"> <Style Selector="Border.pane-drop-hint">
@@ -47,15 +57,8 @@
ToolTip.Tip="{loc:Tr missionControl.newSession}"> ToolTip.Tip="{loc:Tr missionControl.newSession}">
<PathIcon Width="16" Height="16" Data="{Binding Source='mdi:Plus', Converter={StaticResource IconKey}}"/> <PathIcon Width="16" Height="16" Data="{Binding Source='mdi:Plus', Converter={StaticResource IconKey}}"/>
<Button.Flyout> <Button.Flyout>
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening"> <MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening"
<MenuFlyout.ItemContainerTheme> ItemContainerTheme="{StaticResource SessionTargetItemTheme}"/>
<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>
</Button.Flyout> </Button.Flyout>
</Button> </Button>
<Button x:Name="RecentSessionsButton" Classes="icon-btn" <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(); 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. // The view owns the folder picker (it needs a TopLevel); the VM decides what to open with the path.
private async void BrowseForSessionAsync() private async System.Threading.Tasks.Task<string?> PickFolderAsync()
{ {
if (DataContext is not MissionControlViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null) return; if (topLevel is null) return null;
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{ {
Title = "Choose a directory", Title = "Choose a directory",
AllowMultiple = false, AllowMultiple = false,
}); });
if (folders.Count == 0) return; return folders.Count == 0 ? null : folders[0].Path.LocalPath;
await vm.OpenAdHocConPtySessionAsync(folders[0].Path.LocalPath);
} }
private void OnDataContextChanged(object? sender, EventArgs e) private void OnDataContextChanged(object? sender, EventArgs e)
@@ -172,7 +169,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged -= OnPanesChanged; _vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null; _vm.PickFolderAsync = null;
_vm.ConfirmAsync = null; _vm.ConfirmAsync = null;
} }
@@ -182,7 +179,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged += OnPanesChanged; _vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged; _vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync; _vm.PickFolderAsync = PickFolderAsync;
_vm.ConfirmAsync = ShowConfirmAsync; _vm.ConfirmAsync = ShowConfirmAsync;
} }
@@ -852,6 +852,42 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </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> </TabControl>
</Grid> </Grid>
</DockPanel> </DockPanel>
@@ -1,5 +1,8 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Modals;
@@ -17,4 +20,11 @@ public partial class SettingsModalView : Window
if (DataContext is SettingsModalViewModel vm) if (DataContext is SettingsModalViewModel vm)
vm.CloseAction = Close; 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.Equal(4, vm.SessionTargets.Count); // home, browse, Alpha, VPS
Assert.Contains("VPS", vm.SessionTargets[3].Label); 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); var pane = Assert.Single(vm.ConPtySessions);
Assert.Equal("VPS", pane.DisplayTitle); Assert.Equal("VPS", pane.DisplayTitle);
} }
@@ -339,7 +339,7 @@ public class MissionControlViewModelTests : IDisposable
using var vm = BuildVm(new FakeWorker()); using var vm = BuildVm(new FakeWorker());
var browsed = false; var browsed = false;
vm.BrowseForSessionRequested = () => browsed = true; vm.PickFolderAsync = () => { browsed = true; return Task.FromResult<string?>(null); };
await vm.RefreshSessionTargetsAsync(); await vm.RefreshSessionTargetsAsync();
@@ -347,10 +347,65 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(4, vm.SessionTargets.Count); Assert.Equal(4, vm.SessionTargets.Count);
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(2).Select(t => t.Label).ToArray()); 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); 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() private async Task SeedQueueAsync()
{ {
await using var db = NewContext(); await using var db = NewContext();