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
## 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
+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
`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
+1
View File
@@ -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.
+18 -1
View File
@@ -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",
+18 -1
View File
@@ -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",
+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>
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();