Compare commits
26
Commits
f88d2a898e
...
f3ecb99492
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ecb99492 | ||
|
|
a2496e056a | ||
|
|
44ec89b0e6 | ||
|
|
a7c3d18533 | ||
|
|
0d029717f8 | ||
|
|
2be8ba5d66 | ||
|
|
928e3905d9 | ||
|
|
60358100be | ||
|
|
2a41f7e641 | ||
|
|
bb92dc279f | ||
|
|
8a31a67c74 | ||
|
|
f2ff865dca | ||
|
|
5da8036b9a | ||
|
|
cf4b5c507c | ||
|
|
3e2d5bab6a | ||
|
|
27d2b1d8ef | ||
|
|
ccd11a8033 | ||
|
|
9991f1fc1f | ||
|
|
75b78cb2c0 | ||
|
|
05deda8343 | ||
|
|
52ba538339 | ||
|
|
2558e02ba3 | ||
|
|
81dcaebfba | ||
|
|
1396649519 | ||
|
|
5be972564a | ||
|
|
ecb1cdbb06 |
@@ -25,8 +25,8 @@ is wired through `TaskIdResolver`, which resolves:
|
|||||||
This is **not** ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
|
This is **not** ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
|
||||||
number, not a partial GUID. All task-returning tools (`GetTask`, `ListTasks`, `AddTask`,
|
number, not a partial GUID. All task-returning tools (`GetTask`, `ListTasks`, `AddTask`,
|
||||||
`BatchGetTasks`, etc.) stamp the resolved task's `Number` in the DTO — both `TaskDto` and the
|
`BatchGetTasks`, etc.) stamp the resolved task's `Number` in the DTO — both `TaskDto` and the
|
||||||
lean `TaskRefDto` carry an `int Number` field. **Branch names and worktree paths** (`claudedo/{id}`)
|
lean `TaskRefDto` carry an `int Number` field. **Task worktree branches and folders** are `claudedo/{number}-{title-slug}`;
|
||||||
continue to use the GUID; the number is a display alias, never the identity.
|
otherwise the number is a display alias, never the identity.
|
||||||
|
|
||||||
Every tool description carries a shared boilerplate clause (defined in `McpToolDocs.TaskNumberHint`)
|
Every tool description carries a shared boilerplate clause (defined in `McpToolDocs.TaskNumberHint`)
|
||||||
instructing the agent to **refer to tasks as `#<number>` when reporting results to the user** —
|
instructing the agent to **refer to tasks as `#<number>` when reporting results to the user** —
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# Sichtprüfungs-Session 2026-09-16
|
||||||
|
|
||||||
|
Build: Debug 2.15.0 (App 08:05, Worker 09:40 — enthält den uncommitteten `WorktreeUpdated`-Fix).
|
||||||
|
Sandkasten: Liste **ClaudeDoTests** → `C:\TestRepos\ClaudeDoTests` (sauber, alle Alt-Tasks Done).
|
||||||
|
Vorgeseedet: **#334** (README.md) und **#335** (docs/notes.md) — laufen, landen in `WaitingForReview`.
|
||||||
|
|
||||||
|
Abgedeckt wird alles seit dem letzten Sichtprüfungs-Pass (06.08.), also v2.13.x–v2.15.
|
||||||
|
**Bewusst draußen:** Installer/SystemCheckPage (braucht echte Installation) und die
|
||||||
|
Ticketsystem-Anbindung (braucht ein zugewiesenes Ticket) — beide aus `docs/open.md`.
|
||||||
|
|
||||||
|
Notation: `[ ]` offen · `[x]` ok · `[!]` Fehler (Text dahinter = Fix-Kandidat)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pass 1 — Sidebar: Listen & Ordner (~8 min) · **ERLEDIGT** — 1.1–1.8 ok bis auf 1.2 (→ F1)
|
||||||
|
|
||||||
|
> 1.7 verhält sich gut: Ordner löschen schiebt die enthaltene Liste raus statt sie mitzunehmen.
|
||||||
|
|
||||||
|
- [ ] **1.1** Rechtsklick in die Listen-Sidebar → „Neue Liste": im Modal den Ordner-Toggle
|
||||||
|
einschalten → es entsteht ein **Ordner**, nicht eine Liste.
|
||||||
|
- [ ] **1.2** Ordner anklicken → klappt auf/zu, Pfeil dreht sich, Zustand bleibt nach
|
||||||
|
Listenwechsel erhalten.
|
||||||
|
- [ ] **1.3** Eine bestehende Liste per Drag in den Ordner ziehen → landet drin, Ordner zeigt sie.
|
||||||
|
- [ ] **1.4** Liste wieder aus dem Ordner heraus auf die oberste Ebene ziehen → geht zurück.
|
||||||
|
- [ ] **1.5** **Gemischte Reihenfolge** (das ist der Kern von 2.14): eine Liste **über** einen
|
||||||
|
Ordner ziehen → Reihenfolge hält. Ordner per Drag zwischen zwei Listen schieben → hält auch.
|
||||||
|
- [ ] **1.6** App neu starten (oder Worker-Menü → Reconnect) → die gemischte Reihenfolge ist
|
||||||
|
noch dieselbe. *Das ist der Test, der `ReorderSidebarAsync` wirklich prüft.*
|
||||||
|
- [ ] **1.7** Ordner löschen, während eine Liste drin liegt → was passiert mit der Liste?
|
||||||
|
(Erwartung unklar — notieren, was es tut.)
|
||||||
|
- [ ] **1.8** Rechtsklick auf einen Task → „In Liste verschieben" → Ziel wählen → Task ist weg
|
||||||
|
aus der Quelle, da in der Zielliste.
|
||||||
|
|
||||||
|
## Pass 2 — Review, Merge, Stash (~15 min) · der wichtigste Pass
|
||||||
|
|
||||||
|
Braucht #334/#335 in `WaitingForReview`. Sag mir Bescheid, falls sie noch laufen.
|
||||||
|
|
||||||
|
- [ ] **2.1** ⚠️ **Der uncommittete Fix.** Auf einem Task „Open ConPTY session" → im Terminal
|
||||||
|
irgendeine Kleinigkeit ändern → in der Mission-Control-Kachel „Submit for review".
|
||||||
|
**Erwartung:** Der Task landet in `WaitingForReview` und die REVIEW-Karte im Detail-Pane
|
||||||
|
zeigt **sofort** „Open diff" + Änderungs-Zusammenfassung — **ohne** dass du den Task neu
|
||||||
|
anklicken musst. (Vorher fehlte der ganze Block bis zur Neuauswahl.)
|
||||||
|
- [ ] **2.2** #334 anklicken → „Open diff" → Side-by-side-Diff zeigt die README-Änderung,
|
||||||
|
Zeilen/Farben stimmen, kein Abschneiden unten.
|
||||||
|
- [ ] **2.3** Approve-Gate: **ohne** geöffneten Diff ist Approve gesperrt, nach dem Diff aktiv.
|
||||||
|
- [ ] **2.4** Approve auf #334 → merged, Task geht auf **Done**, Chip wechselt, Worker-Log-Zeile
|
||||||
|
im Footer erscheint (mit `#334` im Text — deckt gleich Pass 5.1 mit ab).
|
||||||
|
- [ ] **2.5** 🔥 **Stash-Option (neuester Commit, komplett ungetestet).** Ich mache dir das
|
||||||
|
Ziel-Verzeichnis absichtlich dirty (sag „mach dirty"), dann auf #335 **Approve & Merge**:
|
||||||
|
Erwartung = **Bestätigungsdialog** „Änderungen stashen und mergen?" statt der alten
|
||||||
|
`blocked`-Sackgasse. Bestätigen → Merge läuft, und deine uncommitteten Änderungen in
|
||||||
|
`C:\TestRepos\ClaudeDoTests` sind danach **wieder da**.
|
||||||
|
- [ ] **2.6** Derselbe Fall über das **Merge-Modal** (statt Approve): Button
|
||||||
|
„Änderungen stashen und mergen" ist da und tut dasselbe.
|
||||||
|
- [ ] **2.7** Abbrechen im Stash-Dialog → nichts passiert, Task bleibt `WaitingForReview`,
|
||||||
|
Arbeitsverzeichnis unverändert.
|
||||||
|
- [ ] **2.8** Worktrees-Übersicht öffnen → die gemergten Worktrees stehen als `Merged` drin,
|
||||||
|
Cleanup funktioniert, keine WARN-Flut im Footer.
|
||||||
|
|
||||||
|
## Pass 3 — Interaktive Sessions & Mission Control (~12 min)
|
||||||
|
|
||||||
|
- [ ] **3.1** ConPTY-Session läuft → auf das **X** klicken → **Rückfrage** erscheint
|
||||||
|
(„Session läuft wirklich schließen?"), Abbrechen lässt sie laufen.
|
||||||
|
- [ ] **3.2** Im interaktiven Terminal **Text einfügen** (Ctrl+V) → landet im Prompt.
|
||||||
|
- [ ] **3.3** Eine **Datei ins Terminal droppen** → Pfad wird eingefügt.
|
||||||
|
Und: ein Drop, der scheitern muss (z.B. Ordner statt Datei) → Fehler wird **sichtbar**
|
||||||
|
gemeldet, nicht still geschluckt.
|
||||||
|
- [ ] **3.4** **Session stirbt nach dem Start** (im Terminal `exit` tippen) → Banner erscheint
|
||||||
|
in der Kachel, nicht einfach eine tote schwarze Fläche.
|
||||||
|
- [ ] **3.5** Mission Control: **Home-Ordner als Sitzungsziel** — neue Session ohne Liste/Task
|
||||||
|
starten → läuft im User-Verzeichnis, nicht irgendwo.
|
||||||
|
- [ ] **3.6** Mission Control ist **minimiert** → Handler-Run/Session startet → Fenster wird
|
||||||
|
aus dem Minimiert-Zustand geholt, nicht nur in der Taskleiste blinkend.
|
||||||
|
- [ ] **3.7** Handler-Run per Knopf **abbrechen** → Run endet sauber, Kachel verschwindet/
|
||||||
|
wechselt, kein Geister-Pane.
|
||||||
|
|
||||||
|
## Pass 4 — Menüs, Settings, Fenster-Layout (~12 min)
|
||||||
|
|
||||||
|
- [ ] **4.1** Hilfe-Menü → **Cheat-Sheets** → Fenster öffnet, sechs Sheets (git, Conventional
|
||||||
|
Commits, Claude CLI, Claude-Config, dotnet CLI, PowerShell).
|
||||||
|
- [ ] **4.2** Im Cheat-Sheet: Sektionen sind Expander, Befehle sind Mono-Chips mit
|
||||||
|
**Copy-Button** → Klick kopiert wirklich (irgendwo einfügen und prüfen).
|
||||||
|
- [ ] **4.3** Cheat-Sheet-Fenster **anpinnen** (Topmost) → bleibt über der App, App bleibt
|
||||||
|
bedienbar (modelos).
|
||||||
|
- [ ] **4.4** Hilfe-Menü → „Nach Updates suchen" und „Über" → Version zeigt **2.15.0**
|
||||||
|
(aus der FileVersion, nicht hartkodiert).
|
||||||
|
- [ ] **4.5** Worker-Menü → **„Update Claude CLI"** → führt sichtbar `claude update` aus,
|
||||||
|
Ausgabe landet irgendwo lesbar. *(Achtung: ändert wirklich deine CLI — überspring das,
|
||||||
|
wenn du gerade keine CLI-Version riskieren willst.)*
|
||||||
|
- [ ] **4.6** Worker-Menü → **Shutdown** → Rückfrage → beendet Worker **und** App.
|
||||||
|
*Ganz am Ende machen, sonst ist die Session vorbei.*
|
||||||
|
- [ ] **4.7** Settings → **Ausführung**: die PowerUse-Einstellungen sind hier (nicht mehr im
|
||||||
|
Usage-Monitor), Erklärtexte zu Effort und „Prime Claude" sind da und verständlich.
|
||||||
|
Ungültiges PowerUse-Zeitfenster eingeben → wird abgelehnt/erklärt, nicht still gespeichert.
|
||||||
|
- [ ] **4.8** **Modal-Layout** (Fix vom 02.09., Sichtprüfung offen): Über-Dialog,
|
||||||
|
Merge-Modal, Repo-Import, Worktrees-Übersicht, Merge-Helper-Auswahl **einmal öffnen** und
|
||||||
|
prüfen, dass unten **nichts abgeschnitten** ist (letzte Zeile/Button voll sichtbar).
|
||||||
|
- [ ] **4.9** Listeneinstellungen einer Liste öffnen → Modal erscheint **sofort** (lädt sichtbar
|
||||||
|
nach), und: Arbeitsverzeichnis auf einen Nicht-Git-Ordner setzen → **Warnung + Speichern
|
||||||
|
blockiert**, nicht stillschweigend akzeptiert.
|
||||||
|
- [ ] **4.10** Merge-Helper starten → Start-Modal hat den **Triage-Modell-Picker (opus/fable)**.
|
||||||
|
*(Nur das Modal ansehen, Lauf nicht starten — kostet sonst die halbe Stunde.)*
|
||||||
|
|
||||||
|
## Pass 5 — Reste & bekannte Nits (~8 min)
|
||||||
|
|
||||||
|
- [ ] **5.1** **Task-Nummern:** `#334` steht dimmed vor dem Titel in der Zeile, der Detail-Chip
|
||||||
|
zeigt `#334` (nicht mehr GUID), Footer-Worker-Log schreibt `#334 …`.
|
||||||
|
- [ ] **5.2** **depends_on sichtbar:** zwei Tasks verketten → der blockierte zeigt einen
|
||||||
|
Blocking-Chip, ein Ketten-Label („wartet auf …") und im Detail-Pane den Grund.
|
||||||
|
In der Queue-Zeile hat auch der **blockierte** Task ein Dequeue-**X**.
|
||||||
|
- [ ] **5.3** **Usage-Monitor-Modal → Models-Tab:** die bekannten Nits — kollidierende
|
||||||
|
Spaltenköpfe (`OTHER CACHISHARE`), Zahlen linksbündig ohne Tausendertrenner, Task-Titel
|
||||||
|
hart abgeschnitten. Nur bestätigen, dass sie noch da sind → geht auf die Fix-Liste.
|
||||||
|
- [ ] **5.4** **Deutsch:** einmal die Sprache auf DE stellen und durch Sidebar, Detail-Pane,
|
||||||
|
WorkConsole, ROADBLOCK-Karte und die Merge-Dialoge gehen → keine englischen Reste.
|
||||||
|
*(Der de.json-Konsistenz-Pass vom 10.09. ist nie angeschaut worden.)*
|
||||||
|
- [ ] **5.5** **Worker-Verbindung:** Worker-Menü → Reconnect bei laufendem Worker → Erfolg
|
||||||
|
sichtbar. Und einmal Worker beenden, dann Reconnect → **Fehlschlag wird gemeldet**
|
||||||
|
(Footer-Strip), nicht still no-op.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ergebnis
|
||||||
|
|
||||||
|
Alle fünf Pässe durchlaufen. **11 Befunde** aus ~45 Checks; Pass 4 (Menüs/Settings/Modal-Layout)
|
||||||
|
war komplett sauber. Die drei schweren (F9/F10/F11) sind alle erst ganz am Ende aufgetaucht —
|
||||||
|
Lebenszyklus der App, nicht Features.
|
||||||
|
|
||||||
|
Nicht getestet: Installer/SystemCheckPage, Ticketsystem-Anbindung (beide brauchen echtes Setup),
|
||||||
|
3.7 Handler-Abbruch nur oberflächlich.
|
||||||
|
|
||||||
|
## Delegiert als Tasks (Liste „Claude do", alle Idle)
|
||||||
|
|
||||||
|
| Task | Deckt ab |
|
||||||
|
|---|---|
|
||||||
|
| **#339** Shutdown beendet den Prozess nicht, Worker-Neustart tut nichts | F9 + F11 |
|
||||||
|
| **#340** App-Speicherverbrauch messen — nur Diagnose | F10 |
|
||||||
|
| **#341** duplizierter Confirm-Dialog mit „Delete"-Knopf | F4 + F5 |
|
||||||
|
| **#342** Ordner-Toggle schluckt schnelle Klicks | F1 |
|
||||||
|
| **#343** Diff-Viewer horizontal nicht synchron | F3 |
|
||||||
|
| **#344** dirty Ziel früh melden statt nach dem Fehlschlag | F6 |
|
||||||
|
| **#345** Bilder aus der Zwischenablage ins Terminal | F8 |
|
||||||
|
| **#346** Worktrees-Übersicht: Nummern + Truncation | F7 |
|
||||||
|
|
||||||
|
Nicht delegiert: **F2** (Modell für interaktive Sessions erzwingen) — Entscheidung Mika offen.
|
||||||
|
|
||||||
|
## Fix-Liste
|
||||||
|
|
||||||
|
| # | Pass | Befund | Schwere |
|
||||||
|
|---|------|--------|---------|
|
||||||
|
| F9 | 4.6 | **„Shutdown" beendet die App nicht — der Prozess überlebt.** `IslandsShellViewModel.cs:705` `ShutdownWorkerAndCloseApp` killt den Worker und ruft dann nur `CloseWindow` für das Hauptfenster. Mission Control, Cheat-Sheets und der Diff-Viewer sind eigene `Window`-Instanzen, bleiben offen und halten den Avalonia-Prozess am Leben (bestätigt: alle Nebenfenster standen nach dem „Shutdown" noch offen, `ClaudeDo.App` weiterhin im Task-Manager). Fix: alle Fenster schließen bzw. `IClassicDesktopStyleApplicationLifetime.Shutdown()` statt `CloseWindow`. | **hoch** |
|
||||||
|
| F10 | — | **App-Speicherverbrauch ~1,2 GB.** Gemessen 2026-09-16 am laufenden Debug-Build: `ClaudeDo.App` 1227 MB, `ClaudeDo.Worker` 209 MB. Der Worker wurde 2026-09-02 (`c347e4fc`) bewusst auf Workstation-GC umgestellt, die App offenbar nicht — erster Verdacht, aber ungeprüft. Braucht eine echte Messung (Server-GC? gehaltene Log-/Diff-Puffer? ConPTY-Scrollback?), keinen Blindfix. | **hoch** |
|
||||||
|
| F11 | 5.5 | **Worker-Neustart über den Button funktioniert nicht.** `IslandsShellViewModel.cs:656` `RestartWorkerService` beginnt mit `_workerLocator.Find()` und wirft „Worker executable not found", wenn nichts passt. **Hypothese, nicht verifiziert:** der Locator findet den *installierten* Worker (`C:\Program Files\ClaudeDo`), während der Debug-Build aus dem Repo läuft — dann wird der falsche oder gar kein Prozess gekillt/gestartet. Erst reproduzieren und die Footer-Meldung lesen, dann fixen. | **hoch** |
|
||||||
|
| F8 | 3.2 | **Bilder aus der Zwischenablage lassen sich nicht ins interaktive Terminal einfügen** (Text geht). `InteractiveTerminalViewModel.PasteAsync` → `PtyTerminalSession.PasteAsync` liest nur Text. Lazy Fix: Bild-Clipboard in eine temporäre PNG schreiben und deren Pfad über den bereits funktionierenden Drop-Pfad (`SendTextAsync`) einfügen — Claude liest die Datei dann selbst. Kein neuer Mechanismus nötig. | mittel |
|
||||||
|
| F6 | 2.6 | **Dirty-Target wird zu spät gemeldet.** `dirty_target` kommt erst als Fehlerergebnis *nachdem* der Merge gedrückt und abgelehnt wurde — der Zustand des Ziel-Trees ist aber schon beim Öffnen des Merge-Modals bekannt. Wunsch (Mika): beim Laden des Modals prüfen, Hinweis + „Änderungen stashen und mergen" direkt anbieten; ggf. schon im Diff-Viewer signalisieren. Betrifft beide Pfade (Approve + Merge-Modal). | mittel |
|
||||||
|
| F7 | 2.8 | **Worktrees-Übersicht zeigt keine Task-Nummern.** TASK-Spalte listet nur den Titel; die Task-Numbers-Feature hat Zeile, Detail-Chip und Worker-Log abgedeckt, diese Tabelle nicht. Zusätzlich ist die DIFF-Spalte hart abgeschnitten ohne Ellipse (`docs/notes.m`, `1 file chan`) — gleiches Truncation-Muster wie im Usage-Monitor (F-Nit 5.3). | niedrig |
|
||||||
|
| F4 | 2.5 | **Stash-Bestätigung trägt „Delete" als Bestätigungsknopf.** `DetailsIslandView.axaml.cs:262-263` ist eine zweite, kopierte Confirm-Dialog-Implementierung mit hartkodiertem `Content = "Delete"` (+ `Classes = { "danger" }`); `DetailsIslandViewModel.ConfirmAsync` hängt daran statt am gemeinsamen `WindowDialogService.ConfirmAsync:209` (dort korrekt „Confirm"). Der Stash-Dialog erbt damit die Beschriftung eines Lösch-Dialogs — irreführend bis gefährlich. Fix: Duplikat löschen, VM an den Service-Dialog hängen. | **hoch** |
|
||||||
|
| F5 | 2.5 | **Confirm-/Error-Dialoge sind nicht lokalisiert.** Beide Implementierungen setzen `"Cancel"`/`"Confirm"`/`"Delete"`/`"OK"` und den Fenstertitel `"Confirm"`/`"Error"` als String-Literal, kein `Loc.T`. Fällt mit F4 zusammen erledigen. | mittel |
|
||||||
|
| F3 | 2.2 | **Diff-Viewer scrollt nur vertikal synchron.** `DiffEditorSetup.cs` — die Sync-Klasse heißt `VerticalScrollSync`, Zeile 147 schreibt `new Vector(viewer.Offset.X, y)` und behält das X der Zielspalte bei; horizontal wurde nie verdrahtet, beide Seiten scrollen seitwärts unabhängig. Fix: X 1:1 mitspiegeln (gleiche Schrift/Breite in beiden Panes), Klasse entsprechend umbenennen. | mittel |
|
||||||
|
| F2 | 2.1 | **Feature-Wunsch (kein Bug):** interaktive ConPTY-Sessions starten unter dem Modell aus Mikas Claude-Config, nicht unter dem im Task gewählten. Bewusste Entscheidung, siehe `docs/open.md:35` (`--effort` ja, `--model` nein) — dort schon als möglicher Folge-Task vermerkt. Entscheidung Mika offen. | Wunsch |
|
||||||
|
| F1 | 1.2 | **Ordner-Toggle schluckt schnelle Klicks.** `ListsIslandViewModel.cs:626` `ToggleFolderAsync` ist ein async `[RelayCommand]` → `AsyncRelayCommand` setzt `CanExecute=false` für die Dauer des DB-Writes (`SetFolderCollapsedAsync`); jeder Klick in diesem Fenster wird verworfen. Fix: Toggle synchron (UI-State sofort), Persistierung fire-and-forget. Gleiches Muster in der Sidebar auf andere async RelayCommands gegenprüfen. | mittel |
|
||||||
@@ -214,7 +214,8 @@ sealed class Program
|
|||||||
new MissionControlViewModel(
|
new MissionControlViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<IWorkerClient>(),
|
sp.GetRequiredService<IWorkerClient>(),
|
||||||
sp.GetRequiredService<UsagePillViewModel>()));
|
sp.GetRequiredService<UsagePillViewModel>(),
|
||||||
|
sp.GetRequiredService<AppSettings>()));
|
||||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||||
{
|
{
|
||||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolder
|
|||||||
builder.Property(f => f.Name).HasColumnName("name").IsRequired();
|
builder.Property(f => f.Name).HasColumnName("name").IsRequired();
|
||||||
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
|
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||||
builder.Property(f => f.Icon).HasColumnName("icon");
|
builder.Property(f => f.Icon).HasColumnName("icon");
|
||||||
|
builder.Property(f => f.Color).HasColumnName("color");
|
||||||
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||||
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
|
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
|
||||||
builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id");
|
builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id");
|
||||||
|
|||||||
+1156
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddListFolderColor : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "color",
|
||||||
|
table: "list_folders",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "color",
|
||||||
|
table: "list_folders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -370,6 +370,10 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("Color")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("color");
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public sealed class ListFolderEntity
|
|||||||
public required DateTime CreatedAt { get; init; }
|
public required DateTime CreatedAt { get; init; }
|
||||||
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
|
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
|
||||||
public string? Icon { get; set; }
|
public string? Icon { get; set; }
|
||||||
|
/// <summary>Hex colour (#RRGGBB) for the sidebar name and guide line; null = theme default.</summary>
|
||||||
|
public string? Color { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
|
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
|
||||||
// already being read for the folder itself.
|
// already being read for the folder itself.
|
||||||
|
|||||||
@@ -697,20 +697,21 @@ public static class PromptFiles
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
private const string WeeklyReportDefault = """
|
private const string WeeklyReportDefault = """
|
||||||
You are generating a concise weekly standup report for a software developer,
|
You are writing memory-jogger notes for a developer's weekly standup,
|
||||||
covering {start} to {end}.
|
covering {start} to {end}. They read them aloud in their own words, so write
|
||||||
|
keywords, not sentences.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Write the ENTIRE report in German.
|
- Write in German.
|
||||||
- Group by day. One "## <Wochentag>, <dd.MM.yyyy>" section per day that has
|
- Group by day. One "## <Wochentag>, <dd.MM.>" heading per day that has
|
||||||
activity (German weekday names). Omit days with no activity.
|
activity (German weekday names). Omit days with no activity.
|
||||||
- Within each day: 3–5 first-person, past-tense bullets ("- Habe X umgesetzt",
|
- Per day: 1–3 bullets, each at most ~6 words, noun style
|
||||||
"- Y behoben"). Merge related small work into one bullet.
|
("- LagerApp: Etikettendruck gefixt", "- ClaudeDo Listen-Ordner"). No "Habe …".
|
||||||
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
|
- One bullet per topic — merge everything about the same feature/bug.
|
||||||
- Blend the developer's own notes and the derived activity into ONE deduplicated
|
- Drop trivia: typo fixes, exploration, false starts, tooling/log noise.
|
||||||
bullet list per day. The notes are authoritative — never omit or contradict them.
|
- Merge the developer's notes with the derived activity, deduplicated.
|
||||||
- Name the project/repo when it adds clarity.
|
The notes are authoritative — never omit or contradict them.
|
||||||
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
|
- Output ONLY the day sections. No preamble, no summary, no closing remarks.
|
||||||
|
|
||||||
Two sections follow below: an activity log derived from Claude session history,
|
Two sections follow below: an activity log derived from Claude session history,
|
||||||
and the developer's own notes. Base the report on both; the notes are
|
and the developer's own notes. Base the report on both; the notes are
|
||||||
|
|||||||
@@ -109,16 +109,17 @@ public sealed class ListRepository
|
|||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Patches just the two editable fields — a whole-entity Update would need
|
/// <summary>Patches just the editable fields — a whole-entity Update would need
|
||||||
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
|
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
|
||||||
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
|
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
|
||||||
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default)
|
public async Task RenameFolderAsync(string folderId, string name, string? icon, string? color, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await _context.ListFolders
|
await _context.ListFolders
|
||||||
.Where(f => f.Id == folderId)
|
.Where(f => f.Id == folderId)
|
||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(f => f.Name, name)
|
.SetProperty(f => f.Name, name)
|
||||||
.SetProperty(f => f.Icon, icon), ct);
|
.SetProperty(f => f.Icon, icon)
|
||||||
|
.SetProperty(f => f.Color, color), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
|
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
|
||||||
|
|||||||
@@ -386,6 +386,8 @@
|
|||||||
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
|
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
|
||||||
"focusMode": "Fokus",
|
"focusMode": "Fokus",
|
||||||
"overviewMode": "Übersicht",
|
"overviewMode": "Übersicht",
|
||||||
|
"columns": "Spalten",
|
||||||
|
"columnsAuto": "Auto",
|
||||||
"closeSession": "Sitzung hart beenden",
|
"closeSession": "Sitzung hart beenden",
|
||||||
"closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.",
|
"closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.",
|
||||||
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
||||||
@@ -393,6 +395,7 @@
|
|||||||
"conptyExited": "Sitzung beendet (Code {0}).",
|
"conptyExited": "Sitzung beendet (Code {0}).",
|
||||||
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
||||||
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
|
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
|
||||||
|
"deferredHandoffTitleSuffix": "warte auf Limit-Zurücksetzen ({0})",
|
||||||
"mergeHelperTitle": "Merge-Helfer",
|
"mergeHelperTitle": "Merge-Helfer",
|
||||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||||
@@ -459,6 +462,8 @@
|
|||||||
"createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.",
|
"createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.",
|
||||||
"sectionGeneral": "ALLGEMEIN",
|
"sectionGeneral": "ALLGEMEIN",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"folderColor": "Farbe",
|
||||||
|
"folderColorHint": "Färbt den Ordnernamen und seine Führungslinie in der Seitenleiste.",
|
||||||
"workingDirectory": "Arbeitsverzeichnis",
|
"workingDirectory": "Arbeitsverzeichnis",
|
||||||
"workingDirectoryPlaceholder": "(keines)",
|
"workingDirectoryPlaceholder": "(keines)",
|
||||||
"browse": "Durchsuchen...",
|
"browse": "Durchsuchen...",
|
||||||
@@ -472,6 +477,8 @@
|
|||||||
"sectionIcon": "ICON",
|
"sectionIcon": "ICON",
|
||||||
"icon": "Listen-Icon",
|
"icon": "Listen-Icon",
|
||||||
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
|
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
|
||||||
|
"iconSearchWatermark": "Alle Icons durchsuchen (Englisch, z.B. rocket, car, bug)",
|
||||||
|
"iconSearchTruncated": "Die ersten 200 Treffer werden angezeigt — Suche verfeinern.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
||||||
"sectionVerify": "VERIFIKATION",
|
"sectionVerify": "VERIFIKATION",
|
||||||
|
|||||||
@@ -386,6 +386,8 @@
|
|||||||
"briefOpenFailed": "Couldn't open the brief: {0}",
|
"briefOpenFailed": "Couldn't open the brief: {0}",
|
||||||
"focusMode": "Focus",
|
"focusMode": "Focus",
|
||||||
"overviewMode": "Overview",
|
"overviewMode": "Overview",
|
||||||
|
"columns": "Columns",
|
||||||
|
"columnsAuto": "Auto",
|
||||||
"closeSession": "Kill session",
|
"closeSession": "Kill session",
|
||||||
"closeSessionConfirm": "End the running session? The Claude process will be aborted.",
|
"closeSessionConfirm": "End the running session? The Claude process will be aborted.",
|
||||||
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
||||||
@@ -393,6 +395,7 @@
|
|||||||
"conptyExited": "Session ended (code {0}).",
|
"conptyExited": "Session ended (code {0}).",
|
||||||
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
||||||
"terminalSendFailed": "Couldn't send that to the terminal session.",
|
"terminalSendFailed": "Couldn't send that to the terminal session.",
|
||||||
|
"deferredHandoffTitleSuffix": "waiting for limit reset ({0})",
|
||||||
"mergeHelperTitle": "Merge Helper",
|
"mergeHelperTitle": "Merge Helper",
|
||||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||||
"mergeHelperTaskTitle": "List handler: {0}",
|
"mergeHelperTaskTitle": "List handler: {0}",
|
||||||
@@ -459,6 +462,8 @@
|
|||||||
"createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.",
|
"createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.",
|
||||||
"sectionGeneral": "GENERAL",
|
"sectionGeneral": "GENERAL",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"folderColor": "Colour",
|
||||||
|
"folderColorHint": "Tints the folder name and its guide line in the sidebar.",
|
||||||
"workingDirectory": "Working directory",
|
"workingDirectory": "Working directory",
|
||||||
"workingDirectoryPlaceholder": "(none)",
|
"workingDirectoryPlaceholder": "(none)",
|
||||||
"browse": "Browse...",
|
"browse": "Browse...",
|
||||||
@@ -472,6 +477,8 @@
|
|||||||
"sectionIcon": "ICON",
|
"sectionIcon": "ICON",
|
||||||
"icon": "List icon",
|
"icon": "List icon",
|
||||||
"iconHint": "Shown next to the list name in the sidebar.",
|
"iconHint": "Shown next to the list name in the sidebar.",
|
||||||
|
"iconSearchWatermark": "Search all icons (English, e.g. rocket, car, bug)",
|
||||||
|
"iconSearchTruncated": "Showing the first 200 matches — refine the search.",
|
||||||
"sectionAgent": "AGENT",
|
"sectionAgent": "AGENT",
|
||||||
"resetAgentSettings": "Reset agent settings",
|
"resetAgentSettings": "Reset agent settings",
|
||||||
"sectionVerify": "VERIFICATION",
|
"sectionVerify": "VERIFICATION",
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ public sealed class AppSettings
|
|||||||
public string DiffViewMode { get; set; } = "unified";
|
public string DiffViewMode { get; set; } = "unified";
|
||||||
public bool DiffWrapLines { get; set; }
|
public bool DiffWrapLines { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mission Control overview column count; 0 = auto (derived from the pane count).</summary>
|
||||||
|
public int MissionControlColumns { get; set; }
|
||||||
|
|
||||||
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 —
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||||
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
|
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
|
||||||
|
<PackageReference Include="Material.Icons" Version="3.0.2" />
|
||||||
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
|
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
|
|||||||
@@ -2,27 +2,49 @@ using System.Globalization;
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Data.Converters;
|
using Avalonia.Data.Converters;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using Material.Icons;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Converters;
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Converts an icon key string (e.g. "Sun") to the matching StreamGeometry resource "Icon.Sun".
|
/// Converts an icon key to its geometry: "Sun" → the "Icon.Sun" StreamGeometry resource,
|
||||||
|
/// "mdi:RocketLaunch" → the Material Design Icons path of that <see cref="MaterialIconKind"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class IconKeyConverter : IValueConverter
|
public class IconKeyConverter : IValueConverter
|
||||||
{
|
{
|
||||||
|
/// <summary>Key prefix marking a Material Design Icons kind.</summary>
|
||||||
|
public const string MdiPrefix = "mdi:";
|
||||||
|
|
||||||
public static IconKeyConverter Instance { get; } = new();
|
public static IconKeyConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, Geometry?> MdiCache = new();
|
||||||
|
|
||||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
{
|
{
|
||||||
if (value is not string key || string.IsNullOrEmpty(key))
|
if (value is not string key || string.IsNullOrEmpty(key))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
if (key.StartsWith(MdiPrefix, StringComparison.Ordinal))
|
||||||
|
return MdiGeometry(key[MdiPrefix.Length..]);
|
||||||
|
|
||||||
var resourceKey = $"Icon.{key}";
|
var resourceKey = $"Icon.{key}";
|
||||||
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
|
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
|
||||||
return res as StreamGeometry;
|
return res as StreamGeometry;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses (and caches) the geometry for an MDI kind name; null for unknown names.</summary>
|
||||||
|
private static Geometry? MdiGeometry(string name)
|
||||||
|
{
|
||||||
|
if (MdiCache.TryGetValue(name, out var cached))
|
||||||
|
return cached;
|
||||||
|
var geometry = Enum.TryParse<MaterialIconKind>(name, out var kind)
|
||||||
|
? StreamGeometry.Parse(MaterialIconDataProvider.GetData(kind))
|
||||||
|
: null;
|
||||||
|
MdiCache[name] = geometry;
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ namespace ClaudeDo.Ui.Converters;
|
|||||||
// instantiate a generic FuncValueConverter as a resource; they stay one per type so the
|
// instantiate a generic FuncValueConverter as a resource; they stay one per type so the
|
||||||
// x:Key names in App.axaml keep resolving. Anything with real logic gets its own file.
|
// x:Key names in App.axaml keep resolving. Anything with real logic gets its own file.
|
||||||
|
|
||||||
|
/// <summary>"#RRGGBB" to a brush; anything else falls back to the app resource named by the parameter.</summary>
|
||||||
|
public sealed class HexBrushConverter : OneWayConverter
|
||||||
|
{
|
||||||
|
public static HexBrushConverter Instance { get; } = new();
|
||||||
|
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> value is string s && Color.TryParse(s, out var c)
|
||||||
|
? new SolidColorBrush(c)
|
||||||
|
: Avalonia.Application.Current?.TryGetResource(parameter as string ?? "", null, out var res) == true ? res : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True when the value's string form equals the parameter (segmented-switch active state).</summary>
|
||||||
|
public sealed class EqualsParameterConverter : OneWayConverter
|
||||||
|
{
|
||||||
|
public static EqualsParameterConverter Instance { get; } = new();
|
||||||
|
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> Equals(value?.ToString(), parameter as string);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class UpperCaseConverter : OneWayConverter
|
public sealed class UpperCaseConverter : OneWayConverter
|
||||||
{
|
{
|
||||||
public static UpperCaseConverter Instance { get; } = new();
|
public static UpperCaseConverter Instance { get; } = new();
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
The old stroke version filled into a solid slab. -->
|
The old stroke version filled into a solid slab. -->
|
||||||
<StreamGeometry x:Key="Icon.Inbox">F0 M3 5 H21 V19 H3 Z M5 7 H19 V12 H5 Z M5 14 H19 V17 H5 Z M11 7.6 H13 V9.4 H15 L12 12 L9 9.4 H11 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Inbox">F0 M3 5 H21 V19 H3 Z M5 7 H19 V12 H5 Z M5 14 H19 V17 H5 Z M11 7.6 H13 V9.4 H15 L12 12 L9 9.4 H11 Z</StreamGeometry>
|
||||||
|
|
||||||
|
<!-- Icon.Dot — picker preview for "no icon"; the sidebar renders it as a coloured Ellipse instead. -->
|
||||||
|
<StreamGeometry x:Key="Icon.Dot">M12 7a5 5 0 1 0 0 10a5 5 0 1 0 0-10z</StreamGeometry>
|
||||||
|
|
||||||
<!-- Icon.Folder -->
|
<!-- Icon.Folder -->
|
||||||
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
|
||||||
|
|
||||||
@@ -854,7 +857,7 @@
|
|||||||
<!-- LIST NAV ITEM -->
|
<!-- LIST NAV ITEM -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<Style Selector="Border.list-item">
|
<Style Selector="Border.list-item">
|
||||||
<Setter Property="Padding" Value="10,7" />
|
<Setter Property="Padding" Value="10,4" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
@@ -863,6 +866,10 @@
|
|||||||
<Setter Property="BorderThickness" Value="1.5" />
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
<Setter Property="BorderBrush" Value="Transparent" />
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!-- Folder tree rows sit tighter than the smart lists — a deep tree gets long fast. -->
|
||||||
|
<Style Selector="Border.list-item.tree-row">
|
||||||
|
<Setter Property="Padding" Value="4,4,10,4" />
|
||||||
|
</Style>
|
||||||
<Style Selector="Border.list-item:pointerover">
|
<Style Selector="Border.list-item:pointerover">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
/// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so
|
/// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so
|
||||||
/// the session can hand off cleanly; no pane for the task means nothing happens.</summary>
|
/// the session can hand off cleanly; no pane for the task means nothing happens.</summary>
|
||||||
event Action<string, string>? HandlerUsageWarningEvent;
|
event Action<string, string>? HandlerUsageWarningEvent;
|
||||||
|
/// <summary>A list-handler handoff is deferred because the 5h usage window is exhausted:
|
||||||
|
/// (handlerTaskId, dueAt). Mission Control updates the pane title to show the reset time.</summary>
|
||||||
|
event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
|
||||||
|
|
||||||
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
|
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
|
||||||
event Action? NotesUpdatedEvent;
|
event Action? NotesUpdatedEvent;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string, string>? SessionHandoffRequestedEvent;
|
public event Action<string, string>? SessionHandoffRequestedEvent;
|
||||||
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||||
|
public event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
|
||||||
public event Action? ConnectionRestoredEvent;
|
public event Action? ConnectionRestoredEvent;
|
||||||
public event Action<string>? WorktreeUpdatedEvent;
|
public event Action<string>? WorktreeUpdatedEvent;
|
||||||
public event Action<string>? ListUpdatedEvent;
|
public event Action<string>? ListUpdatedEvent;
|
||||||
@@ -176,6 +177,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message));
|
Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_hub.On<string, DateTimeOffset>("HandlerHandoffDeferred", (taskId, dueAt) =>
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() => HandlerHandoffDeferredEvent?.Invoke(taskId, dueAt));
|
||||||
|
});
|
||||||
|
|
||||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||||
{
|
{
|
||||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
|||||||
public string? ParentFolderId { get; set; }
|
public string? ParentFolderId { get; set; }
|
||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private string _iconKey = "Folder";
|
[ObservableProperty] private string _iconKey = "Folder";
|
||||||
[ObservableProperty] private bool _isCollapsed;
|
/// <summary>Hex colour for name + guide line; null = theme default.</summary>
|
||||||
|
[ObservableProperty] private string? _color;
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowCount))]
|
||||||
|
private bool _isCollapsed;
|
||||||
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
|
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
|
||||||
[ObservableProperty] private int _count;
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowCount))]
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
/// <summary>Badge only while collapsed — expanded, the children show their own counts.</summary>
|
||||||
|
public bool ShowCount => IsCollapsed && Count > 0;
|
||||||
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
|
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
|
||||||
[ObservableProperty] private bool _isDropTarget;
|
[ObservableProperty] private bool _isDropTarget;
|
||||||
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
|
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
@@ -8,7 +9,12 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
|||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required ListKind Kind { get; init; }
|
public required ListKind Kind { get; init; }
|
||||||
[ObservableProperty] private string _name = "";
|
[ObservableProperty] private string _name = "";
|
||||||
[ObservableProperty] private int _count;
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasCount))]
|
||||||
|
private int _count;
|
||||||
|
|
||||||
|
/// <summary>Hides a "0" badge — an empty list reads as noise next to the real counts.</summary>
|
||||||
|
public bool HasCount => Count > 0;
|
||||||
[ObservableProperty] private bool _isActive;
|
[ObservableProperty] private bool _isActive;
|
||||||
[ObservableProperty] private string? _workingDir;
|
[ObservableProperty] private string? _workingDir;
|
||||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||||
@@ -25,7 +31,15 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
|||||||
// DropHintAbove/Below, which mark an insertion point for list reordering.
|
// DropHintAbove/Below, which mark an insertion point for list reordering.
|
||||||
[ObservableProperty] private bool _isTaskDropTarget;
|
[ObservableProperty] private bool _isTaskDropTarget;
|
||||||
// Settable (not init) so a list-settings icon change refreshes the row in place.
|
// Settable (not init) so a list-settings icon change refreshes the row in place.
|
||||||
[ObservableProperty] private string? _iconKey;
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsDot))]
|
||||||
|
private string? _iconKey;
|
||||||
|
|
||||||
|
/// <summary>No icon picked — the row shows a coloured dot instead of a glyph.</summary>
|
||||||
|
public bool IsDot => IconKey is null or ListsIslandViewModel.ListIcon;
|
||||||
|
|
||||||
|
/// <summary>Identity colour of the dot, stable per list id across restarts.</summary>
|
||||||
|
public string DotColorKey => $"Identity{ConPtyPaneViewModel.StableIndex(Id, 6) + 1}";
|
||||||
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
|
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
|
||||||
[ObservableProperty] private string? _folderId;
|
[ObservableProperty] private string? _folderId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
|
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
|
||||||
public const string ListFolderIcon = "Folder";
|
public const string ListFolderIcon = "Folder";
|
||||||
|
|
||||||
|
/// <summary>Icon key a list falls back to — deliberately not the folder icon, so the two stay distinguishable.</summary>
|
||||||
|
public const string ListIcon = ListSettingsModalViewModel.DefaultIcon;
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IServiceProvider? _services;
|
private readonly IServiceProvider? _services;
|
||||||
private readonly IWorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
@@ -349,6 +352,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Id = f.Id,
|
Id = f.Id,
|
||||||
Name = f.Name,
|
Name = f.Name,
|
||||||
IconKey = f.Icon ?? ListFolderIcon,
|
IconKey = f.Icon ?? ListFolderIcon,
|
||||||
|
Color = f.Color,
|
||||||
IsCollapsed = f.IsCollapsed,
|
IsCollapsed = f.IsCollapsed,
|
||||||
ParentFolderId = f.ParentFolderId,
|
ParentFolderId = f.ParentFolderId,
|
||||||
};
|
};
|
||||||
@@ -368,7 +372,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Id = $"user:{l.Id}",
|
Id = $"user:{l.Id}",
|
||||||
Name = l.Name,
|
Name = l.Name,
|
||||||
Kind = ListKind.User,
|
Kind = ListKind.User,
|
||||||
IconKey = l.Icon ?? "Folder",
|
IconKey = l.Icon ?? ListIcon,
|
||||||
WorkingDir = l.WorkingDir,
|
WorkingDir = l.WorkingDir,
|
||||||
DefaultCommitType = l.DefaultCommitType,
|
DefaultCommitType = l.DefaultCommitType,
|
||||||
IsManual = l.IsManual,
|
IsManual = l.IsManual,
|
||||||
@@ -473,7 +477,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Id = $"user:{entity.Id}",
|
Id = $"user:{entity.Id}",
|
||||||
Name = entity.Name,
|
Name = entity.Name,
|
||||||
Kind = ListKind.User,
|
Kind = ListKind.User,
|
||||||
IconKey = "Folder",
|
IconKey = ListIcon,
|
||||||
WorkingDir = entity.WorkingDir,
|
WorkingDir = entity.WorkingDir,
|
||||||
DefaultCommitType = entity.DefaultCommitType,
|
DefaultCommitType = entity.DefaultCommitType,
|
||||||
};
|
};
|
||||||
@@ -755,7 +759,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
if (folder is null || Dialogs is null || _services is null) return;
|
if (folder is null || Dialogs is null || _services is null) return;
|
||||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||||
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey);
|
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey, folder.Color);
|
||||||
await Dialogs.ShowListSettingsAsync(vm);
|
await Dialogs.ShowListSettingsAsync(vm);
|
||||||
// Rename, re-icon and delete all change the folder header (and delete releases its lists
|
// Rename, re-icon and delete all change the folder header (and delete releases its lists
|
||||||
// back to the top level) — one reload covers every outcome.
|
// back to the top level) — one reload covers every outcome.
|
||||||
@@ -840,7 +844,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
row.DefaultCommitType = entity.DefaultCommitType;
|
row.DefaultCommitType = entity.DefaultCommitType;
|
||||||
row.IsManual = entity.IsManual;
|
row.IsManual = entity.IsManual;
|
||||||
row.FindingsTracked = entity.FindingsTracked;
|
row.FindingsTracked = entity.FindingsTracked;
|
||||||
row.IconKey = entity.Icon ?? "Folder";
|
row.IconKey = entity.Icon ?? ListIcon;
|
||||||
row.HasTicketProject = hasTicketProject;
|
row.HasTicketProject = hasTicketProject;
|
||||||
RecomputeHasNoLinkedRepo();
|
RecomputeHasNoLinkedRepo();
|
||||||
}
|
}
|
||||||
@@ -886,7 +890,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Id = $"user:{entity.Id}",
|
Id = $"user:{entity.Id}",
|
||||||
Name = entity.Name,
|
Name = entity.Name,
|
||||||
Kind = ListKind.User,
|
Kind = ListKind.User,
|
||||||
IconKey = entity.Icon ?? "Folder",
|
IconKey = entity.Icon ?? ListIcon,
|
||||||
WorkingDir = entity.WorkingDir,
|
WorkingDir = entity.WorkingDir,
|
||||||
DefaultCommitType = entity.DefaultCommitType,
|
DefaultCommitType = entity.DefaultCommitType,
|
||||||
IsManual = entity.IsManual,
|
IsManual = entity.IsManual,
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
|||||||
if (TaskId is null || ShowDiffViewer is null) return;
|
if (TaskId is null || ShowDiffViewer is null) return;
|
||||||
var vm = _services.GetRequiredService<DiffViewerViewModel>();
|
var vm = _services.GetRequiredService<DiffViewerViewModel>();
|
||||||
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
|
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
|
||||||
await vm.LoadAsync();
|
_ = vm.LoadAsync();
|
||||||
DiffViewed?.Invoke();
|
DiffViewed?.Invoke();
|
||||||
await ShowDiffViewer(vm);
|
await ShowDiffViewer(vm);
|
||||||
}
|
}
|
||||||
@@ -187,7 +187,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
else return;
|
else return;
|
||||||
|
|
||||||
await vm.LoadAsync();
|
_ = vm.LoadAsync();
|
||||||
DiffViewed?.Invoke();
|
DiffViewed?.Invoke();
|
||||||
await ShowDiffViewer(vm);
|
await ShowDiffViewer(vm);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1173,7 +1173,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
||||||
{
|
{
|
||||||
if (_worker is null) return;
|
if (_worker is null) return;
|
||||||
@@ -1237,7 +1237,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
private async Task ToggleStarAsync(TaskRowViewModel row)
|
private async Task ToggleStarAsync(TaskRowViewModel row)
|
||||||
{
|
{
|
||||||
row.IsStarred = !row.IsStarred;
|
row.IsStarred = !row.IsStarred;
|
||||||
@@ -1403,7 +1403,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || _worker is null) return;
|
if (row is null || _worker is null) return;
|
||||||
@@ -1700,7 +1700,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Regroup();
|
Regroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
private async Task RefineTask(TaskRowViewModel row)
|
private async Task RefineTask(TaskRowViewModel row)
|
||||||
{
|
{
|
||||||
if (row is null || !row.CanRefine) return;
|
if (row is null || !row.CanRefine) return;
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
// FNV-1a, not string.GetHashCode: the latter is randomized per process, so the same task would
|
// FNV-1a, not string.GetHashCode: the latter is randomized per process, so the same task would
|
||||||
// change colour on every app start.
|
// change colour on every app start.
|
||||||
private static int StableIndex(string value, int buckets)
|
/// <summary>Process-stable bucket for <paramref name="value"/>; also colours the sidebar list dots.</summary>
|
||||||
|
internal static int StableIndex(string value, int buckets)
|
||||||
{
|
{
|
||||||
uint hash = 2166136261;
|
uint hash = 2166136261;
|
||||||
foreach (var c in value)
|
foreach (var c in value)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly AppSettings? _settings;
|
||||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||||
private readonly Action<string> _onTaskUpdated;
|
private readonly Action<string> _onTaskUpdated;
|
||||||
@@ -25,6 +26,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
|
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
|
||||||
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
|
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
|
||||||
private readonly Action<string, string> _onHandlerUsageWarning;
|
private readonly Action<string, string> _onHandlerUsageWarning;
|
||||||
|
private readonly Action<string, DateTimeOffset> _onHandlerHandoffDeferred;
|
||||||
|
|
||||||
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
||||||
// review/merge/status machinery.
|
// review/merge/status machinery.
|
||||||
@@ -43,6 +45,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
[ObservableProperty] private int _columnCount = 1;
|
[ObservableProperty] private int _columnCount = 1;
|
||||||
|
|
||||||
|
/// <summary>User-picked column count for the overview grid; 0 = auto (derived from the pane count).</summary>
|
||||||
|
[ObservableProperty] private int _columnPreference;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
|
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
|
||||||
private bool _isFocusMode;
|
private bool _isFocusMode;
|
||||||
@@ -93,11 +98,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public UsagePillViewModel UsagePill { get; }
|
public UsagePillViewModel UsagePill { get; }
|
||||||
|
|
||||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill, AppSettings? settings = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
UsagePill = usagePill;
|
UsagePill = usagePill;
|
||||||
|
_settings = settings;
|
||||||
|
_columnPreference = Math.Clamp(settings?.MissionControlColumns ?? 0, 0, MaxColumns);
|
||||||
|
|
||||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||||
Panes.CollectionChanged += OnPanesChanged;
|
Panes.CollectionChanged += OnPanesChanged;
|
||||||
@@ -122,6 +129,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
|
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
|
||||||
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
|
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
|
||||||
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
|
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
|
||||||
|
_onHandlerHandoffDeferred = (taskId, dueAt) => UpdateHandlerSessionTitleAsync(taskId, dueAt);
|
||||||
|
_worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred;
|
||||||
|
|
||||||
_ = RefreshQueueAsync();
|
_ = RefreshQueueAsync();
|
||||||
_ = RefreshSessionTargetsAsync();
|
_ = RefreshSessionTargetsAsync();
|
||||||
@@ -331,6 +340,32 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
||||||
|
|
||||||
|
/// <summary>Upper bound of the column picker.</summary>
|
||||||
|
public const int MaxColumns = 4;
|
||||||
|
|
||||||
|
/// <summary>Sets the column preference from the header picker ("0" = auto) and persists it.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void SetColumns(string value) => ColumnPreference = Math.Clamp(int.Parse(value), 0, MaxColumns);
|
||||||
|
|
||||||
|
partial void OnColumnPreferenceChanged(int value)
|
||||||
|
{
|
||||||
|
UpdateColumnCount();
|
||||||
|
if (_settings is null) return;
|
||||||
|
_settings.MissionControlColumns = value;
|
||||||
|
try { _settings.Save(); }
|
||||||
|
catch (Exception ex) { ErrorReported?.Invoke(ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never more columns than panes, so a wide preference doesn't leave empty cells.
|
||||||
|
private void UpdateColumnCount() => ColumnCount = ColumnPreference > 0
|
||||||
|
? Math.Max(1, Math.Min(ColumnPreference, Panes.Count))
|
||||||
|
: Panes.Count switch
|
||||||
|
{
|
||||||
|
<= 1 => 1,
|
||||||
|
<= 4 => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
|
||||||
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
|
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
|
||||||
// Command Center pane (task-based only).
|
// Command Center pane (task-based only).
|
||||||
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
||||||
@@ -556,6 +591,17 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered"));
|
ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A handler session's handoff is deferred due to usage limits: update the pane title to show
|
||||||
|
// the reset time. No pane = nothing to update, not an error.
|
||||||
|
private void UpdateHandlerSessionTitleAsync(string taskId, DateTimeOffset dueAt)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(taskId)) return;
|
||||||
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return;
|
||||||
|
|
||||||
|
var suffix = Loc.T("missionControl.deferredHandoffTitleSuffix", dueAt.LocalDateTime.ToString("HH:mm"));
|
||||||
|
pane.DisplayTitle += $" — {suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
|
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
|
||||||
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
|
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
|
||||||
private void AddConPtyPane(ConPtyPaneViewModel pane, int? index = null)
|
private void AddConPtyPane(ConPtyPaneViewModel pane, int? index = null)
|
||||||
@@ -702,12 +748,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
ColumnCount = Panes.Count switch
|
UpdateColumnCount();
|
||||||
{
|
|
||||||
<= 1 => 1,
|
|
||||||
<= 4 => 2,
|
|
||||||
_ => 3,
|
|
||||||
};
|
|
||||||
OnPropertyChanged(nameof(HasPanes));
|
OnPropertyChanged(nameof(HasPanes));
|
||||||
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is ConPtyPaneViewModel added)
|
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is ConPtyPaneViewModel added)
|
||||||
FocusedPane = added;
|
FocusedPane = added;
|
||||||
@@ -725,6 +766,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
|
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
|
||||||
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
|
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
|
||||||
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
||||||
|
_worker.HandlerHandoffDeferredEvent -= _onHandlerHandoffDeferred;
|
||||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||||
Panes.CollectionChanged -= OnPanesChanged;
|
Panes.CollectionChanged -= OnPanesChanged;
|
||||||
foreach (var c in ConPtySessions.ToList())
|
foreach (var c in ConPtySessions.ToList())
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ using System.Collections.ObjectModel;
|
|||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Ui.Converters;
|
||||||
using ClaudeDo.Ui.Localization;
|
using ClaudeDo.Ui.Localization;
|
||||||
using ClaudeDo.Ui.Services;
|
using ClaudeDo.Ui.Services;
|
||||||
using ClaudeDo.Ui.ViewModels.Agent;
|
using ClaudeDo.Ui.ViewModels.Agent;
|
||||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Material.Icons;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
@@ -111,7 +113,17 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||||
|
|
||||||
public const string DefaultIcon = "Folder";
|
public const string DefaultIcon = "Dot";
|
||||||
|
|
||||||
|
/// <summary>Folder colour swatches; "" = no colour (theme default).</summary>
|
||||||
|
public static readonly string[] FolderColorOptions =
|
||||||
|
["", "#D4A574", "#C87060", "#D9C27A", "#8B9D7A", "#6FB3A8", "#7FA7C9", "#A38BC9"];
|
||||||
|
|
||||||
|
/// <summary>Selected folder colour, "" for none.</summary>
|
||||||
|
[ObservableProperty] private string _folderColor = "";
|
||||||
|
|
||||||
|
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
|
||||||
|
public const string DefaultFolderIcon = "Folder";
|
||||||
|
|
||||||
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
|
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
|
||||||
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
|
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
|
||||||
@@ -120,7 +132,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
public static readonly string[] IconOptions =
|
public static readonly string[] IconOptions =
|
||||||
[
|
[
|
||||||
// general
|
// general
|
||||||
"Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
|
"Dot", "Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
|
||||||
// dev
|
// dev
|
||||||
"Code", "Terminal", "Database", "Server", "Chip", "Bolt", "Wand", "Flask", "Shield",
|
"Code", "Terminal", "Database", "Server", "Chip", "Bolt", "Wand", "Flask", "Shield",
|
||||||
// status / flow
|
// status / flow
|
||||||
@@ -129,7 +141,66 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
|
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
|
||||||
];
|
];
|
||||||
|
|
||||||
public ObservableCollection<string> Icons { get; } = new(IconOptions);
|
/// <summary>Cap on search hits so the picker's WrapPanel stays responsive.</summary>
|
||||||
|
public const int MaxIconResults = 200;
|
||||||
|
|
||||||
|
// One entry per distinct MDI icon; the enum carries aliases, all of which stay searchable.
|
||||||
|
private static readonly Lazy<(string Key, string Names)[]> MdiIndex = new(() =>
|
||||||
|
Enum.GetNames<MaterialIconKind>()
|
||||||
|
.GroupBy(Enum.Parse<MaterialIconKind>)
|
||||||
|
.Select(g => (IconKeyConverter.MdiPrefix + g.First(), string.Join(' ', g)))
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
/// <summary>Picker items: the curated set, or MDI search hits once the search has 2+ characters.</summary>
|
||||||
|
[ObservableProperty] private IReadOnlyList<string> _icons = IconOptions;
|
||||||
|
|
||||||
|
/// <summary>Free-text filter over the MDI icon names (English).</summary>
|
||||||
|
[ObservableProperty] private string _iconSearch = "";
|
||||||
|
|
||||||
|
/// <summary>True when the search hit more than <see cref="MaxIconResults"/> icons.</summary>
|
||||||
|
[ObservableProperty] private bool _iconResultsTruncated;
|
||||||
|
|
||||||
|
// Separate from Icon: swapping Icons makes the ListBox push null here, which must not wipe the choice.
|
||||||
|
[ObservableProperty] private string? _selectedIconOption = DefaultIcon;
|
||||||
|
|
||||||
|
/// <summary>True for a curated key or an existing "mdi:" kind.</summary>
|
||||||
|
public static bool IsKnownIcon(string? key) =>
|
||||||
|
key is not null && (IconOptions.Contains(key)
|
||||||
|
|| (key.StartsWith(IconKeyConverter.MdiPrefix, StringComparison.Ordinal)
|
||||||
|
&& Enum.TryParse<MaterialIconKind>(key[IconKeyConverter.MdiPrefix.Length..], out _)));
|
||||||
|
|
||||||
|
partial void OnIconSearchChanged(string value)
|
||||||
|
{
|
||||||
|
var q = value.Trim();
|
||||||
|
if (q.Length < 2)
|
||||||
|
{
|
||||||
|
IconResultsTruncated = false;
|
||||||
|
// Keep a chosen MDI icon visible (and selected) in the curated view.
|
||||||
|
Icons = IconOptions.Contains(Icon) ? IconOptions : [Icon, .. IconOptions];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var hits = MdiIndex.Value
|
||||||
|
.Where(e => e.Names.Contains(q, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(e => e.Key)
|
||||||
|
.Take(MaxIconResults + 1)
|
||||||
|
.ToList();
|
||||||
|
IconResultsTruncated = hits.Count > MaxIconResults;
|
||||||
|
Icons = hits.Take(MaxIconResults).ToList();
|
||||||
|
}
|
||||||
|
SelectedIconOption = Icons.Contains(Icon) ? Icon : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedIconOptionChanged(string? value)
|
||||||
|
{
|
||||||
|
if (value is not null) Icon = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnIconChanged(string value)
|
||||||
|
{
|
||||||
|
if (!Icons.Contains(value)) OnIconSearchChanged(IconSearch);
|
||||||
|
else SelectedIconOption = value;
|
||||||
|
}
|
||||||
|
|
||||||
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
|
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
|
||||||
// scoped to this list (list → global inheritance).
|
// scoped to this list (list → global inheritance).
|
||||||
@@ -165,13 +236,14 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
/// Opens the modal on an existing folder: name + icon only, no list config to load and
|
/// Opens the modal on an existing folder: name + icon only, no list config to load and
|
||||||
/// nothing to fetch from the worker, so this needs no async counterpart.
|
/// nothing to fetch from the worker, so this needs no async counterpart.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void BeginLoadFolder(string folderId, string name, string? icon)
|
public void BeginLoadFolder(string folderId, string name, string? icon, string? color = null)
|
||||||
{
|
{
|
||||||
|
FolderColor = color ?? "";
|
||||||
_folderId = folderId;
|
_folderId = folderId;
|
||||||
IsFolder = true;
|
IsFolder = true;
|
||||||
CanToggleFolder = false;
|
CanToggleFolder = false;
|
||||||
Name = name;
|
Name = name;
|
||||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(
|
public async Task LoadAsync(
|
||||||
@@ -195,7 +267,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
FindingsTracked = findingsTracked;
|
FindingsTracked = findingsTracked;
|
||||||
// An unknown/legacy key would leave the picker with no selection and silently reset the
|
// An unknown/legacy key would leave the picker with no selection and silently reset the
|
||||||
// icon on the next save — fall back to the default instead.
|
// icon on the next save — fall back to the default instead.
|
||||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
Icon = IsKnownIcon(icon) ? icon! : DefaultIcon;
|
||||||
WorkingDir = workingDir ?? "";
|
WorkingDir = workingDir ?? "";
|
||||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||||
|
|
||||||
@@ -261,6 +333,13 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
|
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
|
||||||
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
|
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
|
||||||
// otherwise only surface as a Failed task the first time the worker tries to use it.
|
// otherwise only surface as a Failed task the first time the worker tries to use it.
|
||||||
|
// The create-flow toggle swaps the untouched default so a new folder doesn't get the list icon.
|
||||||
|
partial void OnIsFolderChanged(bool value)
|
||||||
|
{
|
||||||
|
if (Icon == (value ? DefaultIcon : DefaultFolderIcon))
|
||||||
|
Icon = value ? DefaultFolderIcon : DefaultIcon;
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnWorkingDirChanged(string value)
|
partial void OnWorkingDirChanged(string value)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
|
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
|
||||||
@@ -304,6 +383,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
CloseAction?.Invoke();
|
CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s;
|
||||||
|
|
||||||
private async Task SaveFolderAsync()
|
private async Task SaveFolderAsync()
|
||||||
{
|
{
|
||||||
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||||
@@ -317,6 +398,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
Name = name,
|
Name = name,
|
||||||
Icon = Icon,
|
Icon = Icon,
|
||||||
|
Color = NullIfEmpty(FolderColor),
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
});
|
});
|
||||||
// The create flow pre-creates a list row for this modal to save against; a folder
|
// The create flow pre-creates a list row for this modal to save against; a folder
|
||||||
@@ -327,7 +409,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await lists.RenameFolderAsync(_folderId, name, Icon);
|
await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(FolderColor));
|
||||||
}
|
}
|
||||||
|
|
||||||
Saved = true;
|
Saved = true;
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Animation;
|
||||||
|
using Avalonia.Animation.Easings;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Styling;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Controls;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Accordion host: animates its height between 0 and the child's natural height, so content
|
||||||
|
/// below slides instead of jumping. The first value (from the binding) applies without animation.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ExpandPanel : Decorator
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Duration = TimeSpan.FromMilliseconds(180);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<bool> IsExpandedProperty =
|
||||||
|
AvaloniaProperty.Register<ExpandPanel, bool>(nameof(IsExpanded));
|
||||||
|
|
||||||
|
/// <summary>0 = collapsed, 1 = fully open; drives the measured height.</summary>
|
||||||
|
public static readonly StyledProperty<double> ProgressProperty =
|
||||||
|
AvaloniaProperty.Register<ExpandPanel, double>(nameof(Progress));
|
||||||
|
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private bool _arranged;
|
||||||
|
|
||||||
|
static ExpandPanel()
|
||||||
|
{
|
||||||
|
AffectsMeasure<ExpandPanel>(ProgressProperty);
|
||||||
|
ClipToBoundsProperty.OverrideDefaultValue<ExpandPanel>(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the content is shown.</summary>
|
||||||
|
public bool IsExpanded
|
||||||
|
{
|
||||||
|
get => GetValue(IsExpandedProperty);
|
||||||
|
set => SetValue(IsExpandedProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Current open fraction, animated between 0 and 1.</summary>
|
||||||
|
public double Progress
|
||||||
|
{
|
||||||
|
get => GetValue(ProgressProperty);
|
||||||
|
set => SetValue(ProgressProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
if (change.Property == IsExpandedProperty)
|
||||||
|
Animate(IsExpanded ? 1 : 0);
|
||||||
|
else if (change.Property == ProgressProperty && Child is { } child)
|
||||||
|
child.IsVisible = Progress > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnAttachedToVisualTree(e);
|
||||||
|
if (Child is { } child) child.IsVisible = Progress > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Animate(double to)
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
var from = Progress;
|
||||||
|
Progress = to;
|
||||||
|
if (!_arranged || from == to) return;
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var animation = new Animation
|
||||||
|
{
|
||||||
|
Duration = Duration,
|
||||||
|
Easing = new CubicEaseOut(),
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(ProgressProperty, from) } },
|
||||||
|
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(ProgressProperty, to) } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
_ = animation.RunAsync(this, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size MeasureOverride(Size availableSize)
|
||||||
|
{
|
||||||
|
if (Child is not { } child) return default;
|
||||||
|
child.Measure(availableSize.WithHeight(double.PositiveInfinity));
|
||||||
|
return new Size(child.DesiredSize.Width, child.DesiredSize.Height * Progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Size ArrangeOverride(Size finalSize)
|
||||||
|
{
|
||||||
|
_arranged = true;
|
||||||
|
// Full natural height, top-aligned; ClipToBounds cuts off what isn't revealed yet.
|
||||||
|
Child?.Arrange(new Rect(0, 0, finalSize.Width, Child.DesiredSize.Height));
|
||||||
|
return finalSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,22 @@
|
|||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
|
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
|
||||||
x:DataType="vm:ListsIslandViewModel">
|
x:DataType="vm:ListsIslandViewModel">
|
||||||
<!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over
|
<!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over
|
||||||
TopLevel (folders and folder-less lists interleaved), and each folder's children resolve the
|
TopLevel (folders and folder-less lists interleaved), and each folder's children resolve the
|
||||||
list template the same way. -->
|
list template the same way. -->
|
||||||
|
<!-- Glyph alignment is a style, not a local value, so the nested class can override it. -->
|
||||||
|
<UserControl.Styles>
|
||||||
|
<Style Selector="Panel.list-glyph > :is(Control)">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Left"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Panel.list-glyph.nested > :is(Control)">
|
||||||
|
<Setter Property="HorizontalAlignment" Value="Center"/>
|
||||||
|
</Style>
|
||||||
|
</UserControl.Styles>
|
||||||
|
|
||||||
<UserControl.DataTemplates>
|
<UserControl.DataTemplates>
|
||||||
<!-- One user-list row. Shared by the top level and by each folder's children, so the
|
<!-- One user-list row. Shared by the top level and by each folder's children, so the
|
||||||
row (context menu, drag handlers, drop indicators) exists once. -->
|
row (context menu, drag handlers, drop indicators) exists once. -->
|
||||||
@@ -19,7 +30,7 @@
|
|||||||
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
Background="{DynamicResource MossBrush}" CornerRadius="1"
|
||||||
IsVisible="{Binding DropHintAbove}"/>
|
IsVisible="{Binding DropHintAbove}"/>
|
||||||
|
|
||||||
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
|
<Border Grid.Row="1" Classes="list-item tree-row" Classes.active="{Binding IsActive}"
|
||||||
Classes.drop-target="{Binding IsTaskDropTarget}"
|
Classes.drop-target="{Binding IsTaskDropTarget}"
|
||||||
Tapped="OnItemTapped"
|
Tapped="OnItemTapped"
|
||||||
DragDrop.AllowDrop="True"
|
DragDrop.AllowDrop="True"
|
||||||
@@ -57,9 +68,11 @@
|
|||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="20,*,Auto">
|
<!-- Inside a folder: a spacer for the chevron slot, so list and subfolder icons line up.
|
||||||
|
At the top level: no spacer, glyph left-aligned to where a folder's chevron starts. -->
|
||||||
|
<Grid ColumnDefinitions="Auto,20,*,Auto">
|
||||||
<!-- Left accent bar for active state -->
|
<!-- Left accent bar for active state -->
|
||||||
<Border Grid.Column="0" Grid.ColumnSpan="3"
|
<Border Grid.Column="0" Grid.ColumnSpan="4"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
CornerRadius="8" IsHitTestVisible="False"
|
CornerRadius="8" IsHitTestVisible="False"
|
||||||
IsVisible="{Binding IsActive}">
|
IsVisible="{Binding IsActive}">
|
||||||
@@ -67,21 +80,29 @@
|
|||||||
Background="{DynamicResource AccentBrush}"
|
Background="{DynamicResource AccentBrush}"
|
||||||
CornerRadius="1"
|
CornerRadius="1"
|
||||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||||
Margin="-8,0,0,0"/>
|
Margin="-2,0,0,0"/>
|
||||||
</Border>
|
</Border>
|
||||||
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
|
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
|
||||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
<Border Grid.Column="0" Width="12"
|
||||||
Width="14" Height="14"
|
IsVisible="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<Panel Grid.Column="1" Classes="list-glyph"
|
||||||
|
Classes.nested="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
|
<PathIcon Classes="list-icon"
|
||||||
|
Width="14" Height="14" IsVisible="{Binding !IsDot}"
|
||||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
<Ellipse Width="8" Height="8" IsVisible="{Binding IsDot}"
|
||||||
|
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</Panel>
|
||||||
<!-- Name -->
|
<!-- Name -->
|
||||||
<TextBlock Grid.Column="1" Classes="list-label"
|
<TextBlock Grid.Column="2" Classes="list-label"
|
||||||
Text="{Binding Name}"
|
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
|
||||||
VerticalAlignment="Center" Margin="8,0"
|
VerticalAlignment="Center" Margin="8,0"
|
||||||
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
||||||
<!-- Count -->
|
<!-- Count -->
|
||||||
<TextBlock Grid.Column="2" Classes="list-count"
|
<TextBlock Grid.Column="3" Classes="list-count"
|
||||||
Text="{Binding Count}"/>
|
Text="{Binding Count}" IsVisible="{Binding HasCount}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -102,7 +123,7 @@
|
|||||||
IsVisible="{Binding DropHintAbove}"/>
|
IsVisible="{Binding DropHintAbove}"/>
|
||||||
|
|
||||||
<StackPanel Grid.Row="1">
|
<StackPanel Grid.Row="1">
|
||||||
<Border Classes="list-item"
|
<Border Classes="list-item tree-row"
|
||||||
Classes.drop-target="{Binding IsDropTarget}"
|
Classes.drop-target="{Binding IsDropTarget}"
|
||||||
Tapped="OnFolderTapped"
|
Tapped="OnFolderTapped"
|
||||||
DragDrop.AllowDrop="True"
|
DragDrop.AllowDrop="True"
|
||||||
@@ -116,32 +137,40 @@
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="12,20,*,Auto">
|
<Grid ColumnDefinitions="12,20,*,Auto">
|
||||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
|
||||||
Width="9" Height="9"
|
Width="9" Height="9"
|
||||||
Data="{StaticResource Icon.ChevronRight}"
|
Data="{StaticResource Icon.ChevronRight}"
|
||||||
IsVisible="{Binding IsCollapsed}"
|
IsVisible="{Binding IsCollapsed}"
|
||||||
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
|
||||||
Width="9" Height="9"
|
Width="9" Height="9"
|
||||||
Data="{StaticResource Icon.ChevronDown}"
|
Data="{StaticResource Icon.ChevronDown}"
|
||||||
IsVisible="{Binding !IsCollapsed}"
|
IsVisible="{Binding !IsCollapsed}"
|
||||||
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||||
<PathIcon Grid.Column="1" Classes="list-icon"
|
<PathIcon Grid.Column="1" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
|
||||||
Width="14" Height="14"
|
Width="14" Height="14"
|
||||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="2" Classes="list-label"
|
<TextBlock Grid.Column="2" Classes="list-label"
|
||||||
Text="{Binding Name}"
|
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
|
||||||
VerticalAlignment="Center" Margin="8,0"
|
VerticalAlignment="Center" Margin="8,0" FontWeight="SemiBold"
|
||||||
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
|
Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextDimBrush}"
|
||||||
|
FontSize="{StaticResource FontSizeBody}"/>
|
||||||
<TextBlock Grid.Column="3" Classes="list-count"
|
<TextBlock Grid.Column="3" Classes="list-count"
|
||||||
Text="{Binding Count}"/>
|
Text="{Binding Count}" IsVisible="{Binding ShowCount}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Children}"
|
<!-- Guide line drops from under the chevron's centre (tree-row padding 4 + half the 9px
|
||||||
IsVisible="{Binding !IsCollapsed}"
|
glyph); children start past it so their highlight never touches the line. -->
|
||||||
Margin="14,0,0,0"/>
|
<ctl:ExpandPanel IsExpanded="{Binding !IsCollapsed}">
|
||||||
|
<Panel>
|
||||||
|
<Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4"
|
||||||
|
Background="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=LineBrush}"
|
||||||
|
IsHitTestVisible="False"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/>
|
||||||
|
</Panel>
|
||||||
|
</ctl:ExpandPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
|
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
|
||||||
|
|||||||
@@ -102,6 +102,27 @@
|
|||||||
</Flyout>
|
</Flyout>
|
||||||
</Button.Flyout>
|
</Button.Flyout>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Border Classes="segmented" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding !IsFocusMode}"
|
||||||
|
ToolTip.Tip="{loc:Tr missionControl.columns}">
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<Button Classes="segment" Content="{loc:Tr missionControl.columnsAuto}"
|
||||||
|
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=0}"
|
||||||
|
Command="{Binding SetColumnsCommand}" CommandParameter="0"/>
|
||||||
|
<Button Classes="segment" Content="1"
|
||||||
|
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=1}"
|
||||||
|
Command="{Binding SetColumnsCommand}" CommandParameter="1"/>
|
||||||
|
<Button Classes="segment" Content="2"
|
||||||
|
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=2}"
|
||||||
|
Command="{Binding SetColumnsCommand}" CommandParameter="2"/>
|
||||||
|
<Button Classes="segment" Content="3"
|
||||||
|
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=3}"
|
||||||
|
Command="{Binding SetColumnsCommand}" CommandParameter="3"/>
|
||||||
|
<Button Classes="segment" Content="4"
|
||||||
|
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=4}"
|
||||||
|
Command="{Binding SetColumnsCommand}" CommandParameter="4"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
<Button Classes="icon-btn"
|
<Button Classes="icon-btn"
|
||||||
Command="{Binding ToggleLayoutCommand}"
|
Command="{Binding ToggleLayoutCommand}"
|
||||||
ToolTip.Tip="{Binding LayoutToggleLabel}">
|
ToolTip.Tip="{Binding LayoutToggleLabel}">
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ public partial class MissionControlView : UserControl
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContextChanged += OnDataContextChanged;
|
DataContextChanged += OnDataContextChanged;
|
||||||
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
|
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
|
||||||
// Resume/Brief buttons don't close a plain Flyout on their own.
|
// Resume/Brief buttons don't close a plain Flyout on their own. Deferred: Click fires before
|
||||||
RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => RecentSessionsButton.Flyout?.Hide());
|
// Button runs its Command, and a synchronous Hide unbinds that Command first.
|
||||||
|
RecentSessionsList.AddHandler(Button.ClickEvent,
|
||||||
|
(_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => RecentSessionsButton.Flyout?.Hide()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
|
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
|
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
|
xmlns:conv="using:ClaudeDo.Ui.Converters"
|
||||||
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
|
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
|
||||||
x:DataType="vm:ListSettingsModalViewModel"
|
x:DataType="vm:ListSettingsModalViewModel"
|
||||||
Title="{loc:Tr modals.listSettings.title}"
|
Title="{loc:Tr modals.listSettings.title}"
|
||||||
@@ -88,6 +89,30 @@
|
|||||||
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4" IsVisible="{Binding IsFolder}">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.folderColor}"/>
|
||||||
|
<ListBox ItemsSource="{x:Static vm:ListSettingsModalViewModel.FolderColorOptions}"
|
||||||
|
SelectedItem="{Binding FolderColor, Mode=TwoWay}"
|
||||||
|
Background="Transparent" BorderThickness="0"
|
||||||
|
HorizontalAlignment="Left">
|
||||||
|
<ListBox.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel/>
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ListBox.ItemsPanel>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="x:String">
|
||||||
|
<!-- "" renders as an empty ring: the "no colour" swatch. -->
|
||||||
|
<Border Width="18" Height="18" Margin="3" CornerRadius="9"
|
||||||
|
BorderThickness="1" BorderBrush="{DynamicResource LineBrightBrush}"
|
||||||
|
Background="{Binding Converter={x:Static conv:HexBrushConverter.Instance}, ConverterParameter=Surface2Brush}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
<TextBlock Text="{loc:Tr modals.listSettings.folderColorHint}"
|
||||||
|
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/>
|
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/>
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
@@ -143,8 +168,13 @@
|
|||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Spacing="8" Margin="0,8,0,0">
|
<StackPanel Spacing="8" Margin="0,8,0,0">
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/>
|
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/>
|
||||||
|
<TextBox Text="{Binding IconSearch}"
|
||||||
|
Watermark="{loc:Tr modals.listSettings.iconSearchWatermark}"/>
|
||||||
|
<TextBlock Text="{loc:Tr modals.listSettings.iconSearchTruncated}"
|
||||||
|
IsVisible="{Binding IconResultsTruncated}"
|
||||||
|
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||||
<ListBox ItemsSource="{Binding Icons}"
|
<ListBox ItemsSource="{Binding Icons}"
|
||||||
SelectedItem="{Binding Icon, Mode=TwoWay}"
|
SelectedItem="{Binding SelectedIconOption, Mode=TwoWay}"
|
||||||
Background="Transparent" BorderThickness="0"
|
Background="Transparent" BorderThickness="0"
|
||||||
HorizontalAlignment="Left">
|
HorizontalAlignment="Left">
|
||||||
<ListBox.ItemsPanel>
|
<ListBox.ItemsPanel>
|
||||||
@@ -157,6 +187,7 @@
|
|||||||
<!-- No Foreground here on purpose: the icon inherits the container's, so the
|
<!-- No Foreground here on purpose: the icon inherits the container's, so the
|
||||||
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
|
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
|
||||||
<PathIcon Width="20" Height="20" Margin="6"
|
<PathIcon Width="20" Height="20" Margin="6"
|
||||||
|
ToolTip.Tip="{Binding}"
|
||||||
Data="{Binding Converter={StaticResource IconKey}}"/>
|
Data="{Binding Converter={StaticResource IconKey}}"/>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ subfolder within their area; the namespace stays the area namespace.
|
|||||||
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
||||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||||
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Branch names and worktree paths continue to use the GUID (`claudedo/{id}`) — the `Number` is a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#<number>` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
|
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Task worktree branches and folders are named `{number}-{title-slug}` (`claudedo/{number}-{title-slug}`; stored in the `worktrees` row, never re-derived) — the `Number` is otherwise a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#<number>` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
|
||||||
|
|
||||||
## Status Model
|
## Status Model
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
|
|||||||
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
|
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
|
||||||
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
|
||||||
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
|
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
|
||||||
- **WorktreeManager** — worktrees on `claudedo/{id}` branches (full task GUID, dashes stripped — a prefix isn't enough to rule out collisions); commits with semantic messages, updates DB with head commit + diff stats
|
- **WorktreeManager** — worktrees on `claudedo/{number}-{title-slug}` branches, same leaf as the folder (`NameFor`; the unique task number rules out collisions, slug capped at 40 chars); commits with semantic messages, updates DB with head commit + diff stats
|
||||||
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
|
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
|
||||||
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
|
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
|
||||||
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
|
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
|
||||||
@@ -309,6 +309,6 @@ distinguish the two singletons). The token never touches `worker.config.json` or
|
|||||||
|
|
||||||
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
|
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
|
||||||
- `--permission-mode auto` by default; legacy `bypassPermissions` settings map to `auto` at dispatch time. `acceptEdits`, `plan`, `default` pass through unchanged.
|
- `--permission-mode auto` by default; legacy `bypassPermissions` settings map to `auto` at dispatch time. `acceptEdits`, `plan`, `default` pass through unchanged.
|
||||||
- Worktree branches follow `claudedo/{id}`.
|
- Task worktree branches follow `claudedo/{number}-{title-slug}`; planning worktrees stay `claudedo/planning/{guid}` (their path is re-derived from the id on resume).
|
||||||
- **Both MCP servers run `Stateless = true`** (`Program.cs`). No `Mcp-Session-Id`, so a worker restart can't 404 a session that outlives it, and a 2026-07-28 client isn't refused back to the initialize handshake. The trade: **no** sampling, elicitation, resource subscriptions, unsolicited notifications, or legacy SSE endpoint — don't reach for those. In-tool progress notifications ride the POST's own response stream and work in both modes. All three consumers (`TaskRunner`, `PlanningSessionManager`, the installer's `RegisterMcpStep`) already use `type: "http"`.
|
- **Both MCP servers run `Stateless = true`** (`Program.cs`). No `Mcp-Session-Id`, so a worker restart can't 404 a session that outlives it, and a 2026-07-28 client isn't refused back to the initialize handshake. The trade: **no** sampling, elicitation, resource subscriptions, unsolicited notifications, or legacy SSE endpoint — don't reach for those. In-tool progress notifications ride the POST's own response stream and work in both modes. All three consumers (`TaskRunner`, `PlanningSessionManager`, the installer's `RegisterMcpStep`) already use `type: "http"`.
|
||||||
- **An MCP tool that can run longer than ~5s reports progress.** Staying silent lets the MCP client abort after 300s idle while the worker keeps working — the caller sees an abort even though the operation is still running. `Lifecycle/ProgressReporter` is the one implementation (elapsed-time reporting via `RunAsync`, per-item `i/n` via `ReportItem`) — thread an `IProgress<ProgressNotificationValue>? progress = null` parameter through instead of writing another polling loop.
|
- **An MCP tool that can run longer than ~5s reports progress.** Staying silent lets the MCP client abort after 300s idle while the worker keeps working — the caller sees an abort even though the operation is still running. `Lifecycle/ProgressReporter` is the one implementation (elapsed-time reporting via `RunAsync`, per-item `i/n` via `ReportItem`) — thread an `IProgress<ProgressNotificationValue>? progress = null` parameter through instead of writing another polling loop.
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ public sealed class HandoffMcpTools
|
|||||||
taskId, survivingTaskIds, round.EffectivePhase, cancellationToken);
|
taskId, survivingTaskIds, round.EffectivePhase, cancellationToken);
|
||||||
if (deferredUntil is null)
|
if (deferredUntil is null)
|
||||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
||||||
|
else
|
||||||
|
await _broadcaster.HandlerHandoffDeferred(taskId, deferredUntil.Value);
|
||||||
|
|
||||||
return new HandoffListHandlerResult(
|
return new HandoffListHandlerResult(
|
||||||
true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil);
|
true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil);
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
|||||||
public Task HandlerUsageWarning(string handlerTaskId, string message) =>
|
public Task HandlerUsageWarning(string handlerTaskId, string message) =>
|
||||||
_hub.Clients.All.SendAsync("HandlerUsageWarning", handlerTaskId, message);
|
_hub.Clients.All.SendAsync("HandlerUsageWarning", handlerTaskId, message);
|
||||||
|
|
||||||
|
// A list-handler handoff is deferred because the 5h usage window is exhausted. Mission Control
|
||||||
|
// updates the pane title to show the reset time, so the user sees when the session will resume.
|
||||||
|
public Task HandlerHandoffDeferred(string handlerTaskId, DateTimeOffset dueAt) =>
|
||||||
|
_hub.Clients.All.SendAsync("HandlerHandoffDeferred", handlerTaskId, dueAt);
|
||||||
|
|
||||||
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
|
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
|
||||||
public Task NotesUpdated() =>
|
public Task NotesUpdated() =>
|
||||||
_hub.Clients.All.SendAsync("NotesUpdated");
|
_hub.Clients.All.SendAsync("NotesUpdated");
|
||||||
|
|||||||
@@ -40,14 +40,12 @@ public sealed class WeekReportService
|
|||||||
public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
|
public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
string[] excludes;
|
string[] excludes;
|
||||||
string model;
|
|
||||||
IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;
|
IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;
|
||||||
|
|
||||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
{
|
{
|
||||||
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||||
excludes = ParseExcludes(settings.ReportExcludedPaths);
|
excludes = ParseExcludes(settings.ReportExcludedPaths);
|
||||||
model = string.IsNullOrWhiteSpace(settings.DefaultModel) ? "sonnet" : settings.DefaultModel;
|
|
||||||
noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
|
noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +63,13 @@ public sealed class WeekReportService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
|
var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
|
||||||
// Guard against argument injection via the model setting: model aliases/ids are
|
// Summarising notes into bullets doesn't need the user's default model.
|
||||||
// alphanumerics, dashes and dots only.
|
const string model = "haiku";
|
||||||
var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray());
|
|
||||||
if (safeModel.Length == 0) safeModel = "sonnet";
|
|
||||||
IReadOnlyList<string> args =
|
IReadOnlyList<string> args =
|
||||||
[
|
[
|
||||||
"-p", "--output-format", "stream-json", "--verbose",
|
"-p", "--output-format", "stream-json", "--verbose",
|
||||||
"--permission-mode", PermissionModeResolver.Resolve(safeModel, "auto"),
|
"--permission-mode", PermissionModeResolver.Resolve(model, "auto"),
|
||||||
"--model", safeModel,
|
"--model", model,
|
||||||
];
|
];
|
||||||
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
|
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ public sealed class WorktreeManager
|
|||||||
private Task BroadcastCreating(string taskId) =>
|
private Task BroadcastCreating(string taskId) =>
|
||||||
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
|
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>Branch leaf and folder name for a task worktree: <c>{number}-{title-slug}</c>, unique via the task number.</summary>
|
||||||
|
public static string NameFor(TaskEntity task)
|
||||||
|
{
|
||||||
|
var titleSlug = CommitMessageBuilder.ToSlug(task.Title);
|
||||||
|
if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-');
|
||||||
|
return titleSlug.Length == 0 ? task.Number.ToString() : $"{task.Number}-{titleSlug}";
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
|
public async Task<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var workingDir = list.WorkingDir
|
var workingDir = list.WorkingDir
|
||||||
@@ -51,10 +59,7 @@ public sealed class WorktreeManager
|
|||||||
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
|
||||||
|
|
||||||
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
|
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
|
||||||
// Use the full task id (dashes stripped) in the branch name so
|
var name = NameFor(task);
|
||||||
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
|
|
||||||
var idForBranch = task.Id.Replace("-", "");
|
|
||||||
var branchName = $"claudedo/{idForBranch}";
|
|
||||||
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
var slug = CommitMessageBuilder.ToSlug(list.Name);
|
||||||
|
|
||||||
string strategy;
|
string strategy;
|
||||||
@@ -68,11 +73,21 @@ public sealed class WorktreeManager
|
|||||||
: _cfg.CentralWorktreeRoot;
|
: _cfg.CentralWorktreeRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
var worktreePath = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
var parentDir = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
||||||
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug, task.Id)
|
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug)
|
||||||
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug, task.Id);
|
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug);
|
||||||
|
|
||||||
worktreePath = Path.GetFullPath(worktreePath);
|
// Numbers restart after a DB reset: a taken name may be another task's unmerged branch,
|
||||||
|
// which the self-heal below would force-delete — the id suffix keeps it untouched.
|
||||||
|
if (Directory.Exists(Path.Combine(parentDir, name))
|
||||||
|
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"claudedo/{name}"))
|
||||||
|
{
|
||||||
|
var id = task.Id.Replace("-", "");
|
||||||
|
name = $"{name}-{id[..Math.Min(8, id.Length)]}";
|
||||||
|
}
|
||||||
|
|
||||||
|
var branchName = $"claudedo/{name}";
|
||||||
|
var worktreePath = Path.GetFullPath(Path.Combine(parentDir, name));
|
||||||
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
|
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
|
||||||
|
|
||||||
// Ensure parent directory exists.
|
// Ensure parent directory exists.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
@@ -73,7 +75,7 @@ public sealed class TicketImportService
|
|||||||
{
|
{
|
||||||
Id = taskId,
|
Id = taskId,
|
||||||
ListId = listId,
|
ListId = listId,
|
||||||
Title = ticket.Title,
|
Title = WebUtility.HtmlDecode(ticket.Title).Trim(),
|
||||||
Description = BuildDescription(ticket),
|
Description = BuildDescription(ticket),
|
||||||
Status = TaskStatus.Idle,
|
Status = TaskStatus.Idle,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -102,7 +104,43 @@ public sealed class TicketImportService
|
|||||||
|
|
||||||
private static string BuildDescription(TicketBoardItem ticket)
|
private static string BuildDescription(TicketBoardItem ticket)
|
||||||
{
|
{
|
||||||
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
|
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : HtmlToMarkdown(ticket.Description);
|
||||||
return body + $"Ticket #{ticket.Id}";
|
return (body.Length > 0 ? body + "\n\n" : "") + $"Ticket #{ticket.Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private const RegexOptions Rx = RegexOptions.IgnoreCase | RegexOptions.Singleline;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wandelt das Rich-Text-HTML des Ticketsystems in das Markdown-Subset von <c>MarkdownView</c>.
|
||||||
|
/// Zeilenumbrüche werden zu Absätzen, weil <c>MarkdownView</c> einfache Umbrüche zusammenzieht.
|
||||||
|
/// </summary>
|
||||||
|
// ponytail: Regex statt HTML-Parser — verschachtelte Listen und Tabellen werden flach; AngleSharp, falls die Tickets das brauchen
|
||||||
|
internal static string HtmlToMarkdown(string html)
|
||||||
|
{
|
||||||
|
var s = Regex.Replace(html, @"\s*\r?\n\s*", " ");
|
||||||
|
s = Regex.Replace(s, @"<(script|style)\b.*?</\1>", "", Rx);
|
||||||
|
s = Regex.Replace(s, @"<h([1-6])\b[^>]*>(.*?)</h\1>",
|
||||||
|
m => $"\n\n{new string('#', Math.Min(int.Parse(m.Groups[1].Value), 3))} {m.Groups[2].Value}\n\n", Rx);
|
||||||
|
s = Regex.Replace(s, @"<(b|strong)\b[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "**"), Rx);
|
||||||
|
s = Regex.Replace(s, @"<(i|em)\b[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "*"), Rx);
|
||||||
|
s = Regex.Replace(s, @"<code\b[^>]*>(.*?)</code>", m => Wrap(m.Groups[1].Value, "`"), Rx);
|
||||||
|
s = Regex.Replace(s, @"<a\b[^>]*href=""([^""]*)""[^>]*>(.*?)</a>",
|
||||||
|
m => m.Groups[2].Value == m.Groups[1].Value ? m.Groups[1].Value : $"{m.Groups[2].Value} ({m.Groups[1].Value})", Rx);
|
||||||
|
s = Regex.Replace(s, @"<ol\b[^>]*>(.*?)</ol>", m =>
|
||||||
|
{
|
||||||
|
var n = 0;
|
||||||
|
return "\n\n" + Regex.Replace(m.Groups[1].Value, @"<li\b[^>]*>", _ => $"\n{++n}. ", Rx) + "\n\n";
|
||||||
|
}, Rx);
|
||||||
|
s = Regex.Replace(s, @"<li\b[^>]*>", "\n- ", Rx);
|
||||||
|
s = Regex.Replace(s, @"</?(ul|ol)\b[^>]*>", "\n\n", Rx);
|
||||||
|
s = Regex.Replace(s, @"<br\s*/?>|</?(p|div|blockquote|tr)\b[^>]*>", "\n\n", Rx);
|
||||||
|
s = Regex.Replace(s, @"<[^>]+>", "");
|
||||||
|
s = WebUtility.HtmlDecode(s).Replace((char)0xA0, ' ');
|
||||||
|
|
||||||
|
var lines = s.Split('\n').Select(l => Regex.Replace(l, @"[ \t]+", " ").Trim());
|
||||||
|
return Regex.Replace(string.Join('\n', lines), @"\n{3,}", "\n\n").Trim();
|
||||||
|
|
||||||
|
static string Wrap(string inner, string mark)
|
||||||
|
=> string.IsNullOrWhiteSpace(inner) ? inner : $"{mark}{inner.Trim()}{mark}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,13 +67,14 @@ public sealed class ListFolderTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using (var ctx = Open())
|
await using (var ctx = Open())
|
||||||
await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star");
|
await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star", "#7FA7C9");
|
||||||
|
|
||||||
await using (var ctx = Open())
|
await using (var ctx = Open())
|
||||||
{
|
{
|
||||||
var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2");
|
var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2");
|
||||||
Assert.Equal("Renamed", folder.Name);
|
Assert.Equal("Renamed", folder.Name);
|
||||||
Assert.Equal("Star", folder.Icon);
|
Assert.Equal("Star", folder.Icon);
|
||||||
|
Assert.Equal("#7FA7C9", folder.Color);
|
||||||
Assert.True(folder.IsCollapsed);
|
Assert.True(folder.IsCollapsed);
|
||||||
Assert.Equal(1, folder.SortOrder);
|
Assert.Equal(1, folder.SortOrder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
public class MissionControlViewModelTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void DeferredHandoffUpdatesConPtyPaneTitleWithResetTime()
|
||||||
|
{
|
||||||
|
var taskId = Guid.NewGuid().ToString();
|
||||||
|
var title = "Test Handler";
|
||||||
|
var dueAt = new DateTimeOffset(2026, 9, 10, 14, 30, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
|
var pane = new ConPtyPaneViewModel(taskId, title, () => Task.FromResult(
|
||||||
|
new TerminalLaunchDescriptor(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())));
|
||||||
|
|
||||||
|
var originalTitle = pane.DisplayTitle;
|
||||||
|
|
||||||
|
// Simulate the update that would happen on HandlerHandoffDeferredEvent
|
||||||
|
var suffix = $"waiting for limit reset ({dueAt.LocalDateTime:HH:mm})";
|
||||||
|
pane.DisplayTitle += $" — {suffix}";
|
||||||
|
|
||||||
|
Assert.NotEqual(originalTitle, pane.DisplayTitle);
|
||||||
|
Assert.Contains("waiting for limit reset", pane.DisplayTitle);
|
||||||
|
Assert.Contains(dueAt.LocalDateTime.ToString("HH:mm"), pane.DisplayTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public event Action<string, string>? SessionHandoffRequestedEvent;
|
public event Action<string, string>? SessionHandoffRequestedEvent;
|
||||||
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||||
public event Action<string, string>? HandlerUsageWarningEvent;
|
public event Action<string, string>? HandlerUsageWarningEvent;
|
||||||
|
public event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
|
||||||
public event Action? NotesUpdatedEvent;
|
public event Action? NotesUpdatedEvent;
|
||||||
public event Action? PrepStartedEvent;
|
public event Action? PrepStartedEvent;
|
||||||
public event Action<string>? PrepLineEvent;
|
public event Action<string>? PrepLineEvent;
|
||||||
@@ -62,6 +63,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
|
||||||
public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath);
|
public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath);
|
||||||
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
|
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
|
||||||
|
public void RaiseHandlerHandoffDeferred(string taskId, DateTimeOffset dueAt) => HandlerHandoffDeferredEvent?.Invoke(taskId, dueAt);
|
||||||
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
|
||||||
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
|
||||||
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||||
|
|||||||
@@ -59,6 +59,42 @@ public class ListSettingsModalViewModelTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FolderToggle_SwapsUntouchedDefaultIcon_KeepsCustomChoice()
|
||||||
|
{
|
||||||
|
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
|
||||||
|
|
||||||
|
vm.IsFolder = true;
|
||||||
|
Assert.Equal(ListSettingsModalViewModel.DefaultFolderIcon, vm.Icon);
|
||||||
|
vm.IsFolder = false;
|
||||||
|
Assert.Equal(ListSettingsModalViewModel.DefaultIcon, vm.Icon);
|
||||||
|
|
||||||
|
vm.Icon = "Star";
|
||||||
|
vm.IsFolder = true;
|
||||||
|
Assert.Equal("Star", vm.Icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IconSearch_FindsMdiIcons_AndClearingKeepsTheChoice()
|
||||||
|
{
|
||||||
|
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
|
||||||
|
|
||||||
|
vm.IconSearch = "rocket";
|
||||||
|
Assert.Contains("mdi:RocketLaunch", vm.Icons);
|
||||||
|
Assert.DoesNotContain("Star", vm.Icons);
|
||||||
|
|
||||||
|
vm.SelectedIconOption = "mdi:RocketLaunch";
|
||||||
|
vm.SelectedIconOption = null; // what the ListBox pushes when Icons is swapped
|
||||||
|
Assert.Equal("mdi:RocketLaunch", vm.Icon);
|
||||||
|
|
||||||
|
vm.IconSearch = "";
|
||||||
|
Assert.Equal("mdi:RocketLaunch", vm.Icons[0]);
|
||||||
|
Assert.Equal("mdi:RocketLaunch", vm.SelectedIconOption);
|
||||||
|
|
||||||
|
Assert.True(ListSettingsModalViewModel.IsKnownIcon("mdi:RocketLaunch"));
|
||||||
|
Assert.False(ListSettingsModalViewModel.IsKnownIcon("mdi:NoSuchIcon"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
|
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -491,6 +491,33 @@ public class MissionControlViewModelTests : IDisposable
|
|||||||
Assert.Equal(2, vm.ColumnCount);
|
Assert.Equal(2, vm.ColumnCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"claudedo_mc_cfg_{Guid.NewGuid():N}.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
using var vm = new MissionControlViewModel(new TestDbFactory(NewContext), worker,
|
||||||
|
new UsagePillViewModel(worker), new AppSettings { ConfigPath = path });
|
||||||
|
for (var i = 1; i <= 4; i++) await vm.OpenConPtySessionAsync($"t{i}");
|
||||||
|
Assert.Equal(2, vm.ColumnCount); // auto: 4 panes -> 2x2
|
||||||
|
|
||||||
|
vm.SetColumnsCommand.Execute("3");
|
||||||
|
Assert.Equal(3, vm.ColumnCount);
|
||||||
|
|
||||||
|
vm.SetColumnsCommand.Execute("4");
|
||||||
|
vm.Panes[0].CloseCommand.Execute(null);
|
||||||
|
Assert.Equal(3, vm.ColumnCount); // never more columns than panes
|
||||||
|
|
||||||
|
Assert.Equal(4, AppSettings.Load(path).MissionControlColumns);
|
||||||
|
|
||||||
|
vm.SetColumnsCommand.Execute("0");
|
||||||
|
Assert.Equal(2, vm.ColumnCount);
|
||||||
|
}
|
||||||
|
finally { try { File.Delete(path); } catch { } }
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes()
|
public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Lifecycle;
|
||||||
|
|
||||||
|
// The detail pane gates its whole REVIEW/MERGE block (Open diff included) on the worktree row it
|
||||||
|
// last read, and it only re-reads that on WorktreeUpdated -- TaskUpdated refreshes the task row
|
||||||
|
// alone. An interactive session creates/commits its worktree entirely outside the autonomous
|
||||||
|
// pipeline, so without a WorktreeUpdated here the task lands in WaitingForReview with no diff
|
||||||
|
// button at all until it is re-selected.
|
||||||
|
public sealed class InteractiveReviewSubmissionServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly List<GitRepoFixture> _repos = new();
|
||||||
|
private readonly CapturingHubContext _hub = new();
|
||||||
|
|
||||||
|
public InteractiveReviewSubmissionServiceTests() => _ctx = _db.CreateContext();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var r in _repos) r.Dispose();
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private GitRepoFixture CreateRepo()
|
||||||
|
{
|
||||||
|
var f = new GitRepoFixture();
|
||||||
|
_repos.Add(f);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private InteractiveReviewSubmissionService BuildSut()
|
||||||
|
{
|
||||||
|
var cfg = new WorkerConfig
|
||||||
|
{
|
||||||
|
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
||||||
|
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
||||||
|
};
|
||||||
|
var git = new GitService();
|
||||||
|
var dbFactory = _db.CreateFactory();
|
||||||
|
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
|
return new InteractiveReviewSubmissionService(
|
||||||
|
dbFactory, git, state, wtManager, new HubBroadcaster(_hub));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(TaskEntity Task, ListEntity List)> SeedAsync(string workingDir, string? handlerBaseCommit)
|
||||||
|
{
|
||||||
|
var lists = new ListRepository(_ctx);
|
||||||
|
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = workingDir, CreatedAt = DateTime.UtcNow };
|
||||||
|
await lists.AddAsync(list);
|
||||||
|
var task = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "t",
|
||||||
|
Status = TaskStatus.Idle, HandlerBaseCommit = handlerBaseCommit,
|
||||||
|
CreatedAt = DateTime.UtcNow, CommitType = "chore",
|
||||||
|
};
|
||||||
|
await new TaskRepository(_ctx).AddAsync(task);
|
||||||
|
return (task, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool BroadcastWorktreeUpdatedFor(string taskId) =>
|
||||||
|
_hub.Proxy.Calls.Any(c => c.Method == "WorktreeUpdated" && c.Args.Length > 0 && (string?)c.Args[0] == taskId);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_WorktreeTask_BroadcastsWorktreeUpdated()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
var (task, list) = await SeedAsync(repo.RepoDir, handlerBaseCommit: null);
|
||||||
|
|
||||||
|
var sut = BuildSut();
|
||||||
|
var cfg = new WorkerConfig
|
||||||
|
{
|
||||||
|
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
||||||
|
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
||||||
|
};
|
||||||
|
var wtManager = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
var wt = await wtManager.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "hand-driven.txt"), "content");
|
||||||
|
|
||||||
|
await sut.SubmitAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(BroadcastWorktreeUpdatedFor(task.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_WorktreeLessHandlerTask_BroadcastsWorktreeUpdated()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
var (task, _) = await SeedAsync(repo.RepoDir, handlerBaseCommit: repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled");
|
||||||
|
|
||||||
|
await BuildSut().SubmitAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(BroadcastWorktreeUpdatedFor(task.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Submit_RejectedTask_DoesNotBroadcastWorktreeUpdated()
|
||||||
|
{
|
||||||
|
var (task, _) = await SeedAsync(Path.GetTempPath(), handlerBaseCommit: null);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
BuildSut().SubmitAsync(task.Id, CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.False(BroadcastWorktreeUpdatedFor(task.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ public class WeekReportPromptBuilderTests
|
|||||||
new DateOnly(2026, 5, 28), new DateOnly(2026, 6, 3),
|
new DateOnly(2026, 5, 28), new DateOnly(2026, 6, 3),
|
||||||
new[] { repoA }, notes);
|
new[] { repoA }, notes);
|
||||||
|
|
||||||
Assert.Contains("Write the ENTIRE report in German", prompt);
|
Assert.Contains("Write in German", prompt);
|
||||||
var idxJun1 = prompt.IndexOf("2026-06-01", StringComparison.Ordinal);
|
var idxJun1 = prompt.IndexOf("2026-06-01", StringComparison.Ordinal);
|
||||||
var idxJun2 = prompt.IndexOf("2026-06-02", StringComparison.Ordinal);
|
var idxJun2 = prompt.IndexOf("2026-06-02", StringComparison.Ordinal);
|
||||||
Assert.True(idxJun1 >= 0 && idxJun2 > idxJun1);
|
Assert.True(idxJun1 >= 0 && idxJun2 > idxJun1);
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
|
|
||||||
Assert.NotNull(ctx);
|
Assert.NotNull(ctx);
|
||||||
Assert.True(Directory.Exists(ctx.WorktreePath));
|
Assert.True(Directory.Exists(ctx.WorktreePath));
|
||||||
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
|
// StartsWith: a folder leaked into the shared %TEMP% root by another test forces the id suffix.
|
||||||
|
Assert.StartsWith($"claudedo/{task.Number}-test-task", ctx.BranchName);
|
||||||
|
Assert.StartsWith($"{task.Number}-test-task", Path.GetFileName(ctx.WorktreePath));
|
||||||
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
|
||||||
|
|
||||||
using var readCtx = db.CreateContext();
|
using var readCtx = db.CreateContext();
|
||||||
@@ -77,6 +79,15 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
Assert.Null(row.HeadCommit);
|
Assert.Null(row.HeadCommit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Fix: Löschen des Tasks!", "7-fix-lschen-des-tasks")]
|
||||||
|
[InlineData("äöü", "7")]
|
||||||
|
[InlineData("a very long title that goes well beyond the forty char cap", "7-a-very-long-title-that-goes-well-beyond")]
|
||||||
|
public void NameFor_NumberPlusCappedTitleSlug(string title, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, WorktreeManager.NameFor(new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow }));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
|
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
|
||||||
{
|
{
|
||||||
@@ -107,6 +118,28 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
Assert.Equal(WorktreeManager.PhaseCreatingWorktree, (string?)progressCalls[0].Args[1]);
|
Assert.Equal(WorktreeManager.PhaseCreatingWorktree, (string?)progressCalls[0].Args[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_NameTakenByForeignBranch_FallsBackToIdSuffixAndLeavesBranchIntact()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var repo = CreateRepo();
|
||||||
|
var (task, list) = MakeEntities(repo.RepoDir);
|
||||||
|
var (mgr, _) = await CreateManagerAsync(task, list);
|
||||||
|
|
||||||
|
// A leftover from an earlier DB whose task happened to get the same number and title.
|
||||||
|
var foreign = $"claudedo/{WorktreeManager.NameFor(task)}";
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", foreign, repo.BaseCommit);
|
||||||
|
|
||||||
|
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||||
|
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||||
|
|
||||||
|
var suffixed = $"{WorktreeManager.NameFor(task)}-{task.Id.Replace("-", "")[..8]}";
|
||||||
|
Assert.Equal($"claudedo/{suffixed}", ctx.BranchName);
|
||||||
|
Assert.Equal(suffixed, Path.GetFileName(ctx.WorktreePath));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", foreign)));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateAsync_SelfHealPath_BroadcastsCreatingWorktreePhaseForEachSection()
|
public async Task CreateAsync_SelfHealPath_BroadcastsCreatingWorktreePhaseForEachSection()
|
||||||
{
|
{
|
||||||
@@ -122,9 +155,11 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
await new TaskRepository(seedCtx).AddAsync(task);
|
await new TaskRepository(seedCtx).AddAsync(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-create the branch so `git worktree add -b` hits the "already exists" self-heal path.
|
// Occupy the plain name (-> id-suffixed fallback) and the fallback too, so
|
||||||
var branchName = $"claudedo/{task.Id.Replace("-", "")}";
|
// `git worktree add -b` hits the "already exists" self-heal path.
|
||||||
|
var branchName = $"claudedo/{WorktreeManager.NameFor(task)}";
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", $"{branchName}-{task.Id.Replace("-", "")[..8]}", repo.BaseCommit);
|
||||||
|
|
||||||
var fakeHub = new CapturingHubContext();
|
var fakeHub = new CapturingHubContext();
|
||||||
var broadcaster = new HubBroadcaster(fakeHub);
|
var broadcaster = new HubBroadcaster(fakeHub);
|
||||||
@@ -276,13 +311,13 @@ public class WorktreeManagerTests : IDisposable
|
|||||||
await Assert.ThrowsAnyAsync<Exception>(
|
await Assert.ThrowsAnyAsync<Exception>(
|
||||||
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
() => mgr.CreateAsync(task, list, CancellationToken.None));
|
||||||
|
|
||||||
var branchName = $"claudedo/{task.Id.Replace("-", "")}";
|
var branchName = $"claudedo/{WorktreeManager.NameFor(task)}";
|
||||||
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
|
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
|
||||||
Assert.True(string.IsNullOrWhiteSpace(branchList),
|
Assert.True(string.IsNullOrWhiteSpace(branchList),
|
||||||
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
|
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
|
||||||
|
|
||||||
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
|
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
|
||||||
Assert.DoesNotContain(task.Id, worktreeList);
|
Assert.DoesNotContain(WorktreeManager.NameFor(task), worktreeList);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using ClaudeDo.Worker.Tickets;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Tickets;
|
||||||
|
|
||||||
|
public sealed class TicketHtmlToMarkdownTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Paragraphs_breaks_and_entities_become_markdown_paragraphs()
|
||||||
|
{
|
||||||
|
var md = TicketImportService.HtmlToMarkdown(
|
||||||
|
"<p>Lager -> Adressprüfung aus</p>Zeile 1<br>Zeile 2<div><br></div><div>Ende</div>");
|
||||||
|
|
||||||
|
Assert.Equal("Lager -> Adressprüfung aus\n\nZeile 1\n\nZeile 2\n\nEnde", md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Lists_and_inline_formatting_map_to_the_MarkdownView_subset()
|
||||||
|
{
|
||||||
|
var md = TicketImportService.HtmlToMarkdown(
|
||||||
|
"<p>Offene Punkte:</p><ul><li class=\"MsoNormal\"><b>Band</b> sync</li><li>Waage</li></ul>" +
|
||||||
|
"<ol><li>Erst</li><li><em>Dann</em></li></ol><a href=\"http://x\">Link</a>");
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"Offene Punkte:\n\n- **Band** sync\n- Waage\n\n1. Erst\n2. *Dann*\n\nLink (http://x)", md);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plain_text_passes_through()
|
||||||
|
=> Assert.Equal("Ticket ohne HTML", TicketImportService.HtmlToMarkdown(" Ticket ohne HTML "));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user