Skip to content

feat: hierarchical workspace groups with templates, scripts, and project management - #2156

Closed
hirscr wants to merge 8 commits into
manaflow-ai:mainfrom
hirscr:feat/hierarchical-workspace-groups
Closed

hirscr wants to merge 8 commits into
manaflow-ai:mainfrom
hirscr:feat/hierarchical-workspace-groups

Conversation

@hirscr

@hirscr hirscr commented Mar 25, 2026 •

Copy link
Copy Markdown

Summary

Adds collapsible, hierarchical workspace groups to the sidebar — workspaces can have child workspaces (max 3 levels), creating project-based grouping with visual separation.

Features

  • Workspace hierarchy: workspaces have childWorkspaceIds, isCollapsed, collapse/expand via chevron
  • Color cards: workspaces show their color as a card background, children inherit parent color
  • Templates: YAML-based project templates in ~/.config/cmux/templates/ that create multi-tab hierarchies with startup commands
  • Scripts: shell scripts in ~/.config/cmux/scripts/ runnable from context menu or Scripts menu
  • Scripts menu: top-level menu bar item with Run Script, Open Template, Manage Templates, Manage Scripts
  • Template Manager: editor window with YAML editor and help sidebar with cmux command reference
  • Script Manager: editor window for shell scripts
  • Startup command persistence: commands stored on workspaces, re-run on session restore via onPromptReady
  • Drag-and-drop reordering: hierarchy-aware with cross-level promote/demote (child↔tier 1)
  • Socket API: group., project.open, project.open_template, script., template.* commands
  • CLI: cmux open /path command
  • Finder service: "Open in cmux" service
  • Context menus: Add Child Workspace, Make Child, Raise Level, Run Script

Security hardening

  • Path traversal sanitization on script/template names (rejects /, \, :, ..)
  • Socket auth: cmuxOnly mode rejects connections when LOCAL_PEERPID unavailable (no same-UID fallback)
  • Error surfacing in Script/Template Manager view models (replaced silent try? with user-visible errors)

UI polish

  • 10px visual separation between top-level workspace groups
  • Two-zone drop indicator at group boundaries (last-child vs promote-to-tier-1)
  • Duplicate icon changed to plus.square.on.square
  • Read-only preferredTabManager for menu lookups (ported from upstream 6bd9894)

Test plan

  • Create workspace with children (Add Child Workspace from context menu)
  • Collapse/expand groups via chevron
  • Drag-and-drop: reorder tier 1, reorder children within parent, promote child to tier 1, demote tier 1 to child
  • Drop indicator shows correct position at group boundaries
  • Templates: create from template via + button or Scripts menu
  • Scripts: run script from context menu and Scripts menu (greyed out when not at prompt)
  • Script/Template Manager: add, edit, duplicate, delete with error feedback
  • Session persistence: close and reopen — hierarchy, colors, startup commands restored
  • Path traversal: script/template names with / or .. are rejected

🤖 Generated with Claude Code


Summary by cubic

Adds collapsible, hierarchical workspace groups (max 3 levels) and a templates/scripts system so projects open with the right tabs and commands faster. Also adds a Scripts menu, project open flow (CLI + Finder), drag-and-drop hierarchy, and tighter socket/security checks.

  • New Features

    • Hierarchical workspaces: parent/child with collapse/expand, color inheritance, 10px group spacing, and hierarchy-aware drag-and-drop (reorder, promote/demote) with clear drop indicators.
    • Templates & scripts: YAML templates in ~/.config/cmux/templates/ and shell scripts in ~/.config/cmux/scripts/; Template Manager and Script Manager editors; menu bar “Scripts” with Run Script, Open Template, and managers.
    • Project open: .cmux.yaml parsing, CLI cmux open /path, and a Finder “Open in cmux” service; adds socket commands for groups, projects, scripts, and templates.
    • Startup commands: persist on workspaces and run when projects open (prompt-aware via onPromptReady and sendInteractiveText); skipped on session restore to avoid duplicates.
    • Persistence & tests: session snapshot stores parent/child and collapse state; broad tests for configs, repositories, grouping, and snapshots.
  • Bug Fixes

    • Security: reject socket connections in cmuxOnly mode when peer PID is unavailable; sanitize script/template names to block path traversal (/, \, :, ..).
    • Reliability: surface save/edit errors in Template/Script managers instead of failing silently.
    • UI polish: replaced SwiftUI sidebar context menu with a static NSMenu to eliminate submenu flicker; reduced Scripts menu flicker and restored prompt-aware disabling for Run Script; marked close-dialog buttons as destructive for better dark-mode visibility; updated duplicate icon.

Written for commit 4614ccc. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Open projects directly (CLI & Finder “Open in cmux”) with automatic .cmux.yaml handling
    • Workspace grouping: collapsible parent/child hierarchy with nesting, chevrons, and improved drag‑and‑drop
    • Workspace templates and template browser for creating structured workspace trees
    • Script management and a Scripts menu to run/manage reusable terminal scripts
    • New Script & Template manager windows and editors; improved monospace editor and startup‑script scheduling
    • Session restore now preserves group hierarchy, collapse state, and per‑workspace startup commands

hirscr and others added 6 commits March 24, 2026 23:27
…project management

- Workspaces can have child workspaces (max 3 levels deep)
- Collapsible hierarchy with color-coded card backgrounds
- Templates system for multi-tab project setups with startup commands
- Script repository for reusable shell scripts
- Scripts menu bar with Run Script, Open Template, Manage Templates/Scripts
- Template Manager and Script Manager editor windows
- Startup commands persist and re-run on session restore (lightning bolt indicator)
- Finder "Open in cmux" service
- Context menu: Add Child, Make Child, Raise Level, Run Script
- Socket API: group.*, project.open, project.open_template, script.*, template.*
- CLI: cmux open command
- Prompt-ready detection for reliable command execution via sendInteractiveText

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tories

Names from the socket API and UI are now validated through NameSanitizer
before being interpolated into filesystem paths. Rejects directory traversal
vectors (/, \, :, ..) to prevent escaping ~/.config/cmux/.

Also fixes pre-existing TemplateRepositoryTests to use current WorkspaceTemplate API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…Only mode

Previously fell back to same-UID check when LOCAL_PEERPID returned nil,
which is weaker than the descendant-process guarantee cmuxOnly promises.
Now rejects outright when ancestry cannot be verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace try? with do/catch in save, add, duplicate, and delete operations
so users see error messages instead of silent failures. Adds errorMessage
property and red error banner to ScriptManagerWindow matching the existing
TemplateManagerWindow pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add read-only preferredTabManager to AppDelegate (ported from upstream
  6bd9894) to avoid mutation-triggered re-evaluation in menu lookups
- Decouple ScriptsMenuContent from activeTabManager parameter; resolve
  live state only in action closures
- Add Equatable conformance + .equatable() to minimize body re-evaluation
- Restore .disabled(!isAtPrompt) on Run Script items for proper greyed-out state
- Change duplicate icon from doc.on.doc to plus.square.on.square in both
  Script Manager and Template Manager windows

Note: cosmetic highlight flicker in the Run Script submenu persists during
active terminal output due to a SwiftUI CommandMenu framework limitation
(Scene-level invalidation rebuilds the NSMenu bridge regardless of content
stability). Clears up once terminal output settles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pacing

- Add moveWorkspaceByIndicator to TabManager for cross-level drag-and-drop:
  top-level reorder, child reorder, promote (child→tier 1), demote
  (tier 1→child), with depth constraint checks and cycle prevention
- Update SidebarTabDropDelegate to use visible workspace list instead of
  flat tabs array, and call moveWorkspaceByIndicator with indicator info
- Add SidebarGroupSpacerDropDelegate for two-zone drop target in the 10px
  spacer between tier 1 groups (upper half = last child, lower half = tier 1)
- Add showsBottomDropIndicator and bottom overlay on TabItemView for correct
  indicator positioning at group boundaries
- Fix showsCenteredTopDropIndicator to suppress top line at group boundaries
- Override indicator in updateDropIndicator when planner returns no-op at
  group boundaries (bottom-of-self at boundary is meaningful)
- Add 10px spacing above each tier 1 workspace for visual group separation
- Fix shouldShowTopDropIndicator in SidebarEmptyArea to use visible workspaces

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Mar 25, 2026

Copy link
Copy Markdown

@hirscr is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Mar 25, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9458df99-8ae1-4332-9f38-d219a20bd99a

📥 Commits

Reviewing files that changed from the base of the PR and between d84739d and 4614ccc.

📒 Files selected for processing (12)
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Info.plist
  • Sources/AppDelegate.swift
  • Sources/ContentView.swift
  • Sources/ScriptsMenu.swift
  • Sources/SidebarContextMenuActions.swift
  • Sources/SidebarContextMenuController.swift
  • Sources/SidebarContextMenuHost.swift
  • Sources/TabItemDisplaySnapshot.swift
  • Sources/TabManager.swift
  • Sources/Workspace.swift
  • cmuxTests/FocusedPanelMenuStateTests.swift

📝 Walkthrough

Walkthrough

Adds a parent–child workspace hierarchy with sidebar grouping, template and script repositories and UIs, YAML project config parsing, CLI/socket v2 project/group/script/template commands (including a new cmux open flow with connect/retry), startup-script scheduling tied to prompt readiness, and extensive sidebar/UI interaction updates.

Changes

Cohort / File(s) Summary
CLI & v2 project open
CLI/cmux.swift
Adds top-level cmux open command, new openProjectPath(...) flow that calls project.open v2, and a connect-fallback that launches the app and retries up to 10s.
Socket v2 API: project/group/script/template
Sources/TerminalController.swift, Sources/TerminalControllerProjectCommands.swift, Sources/TerminalControllerGroupCommands.swift, Sources/TerminalControllerScriptTemplateCommands.swift
Expose new v2 methods for project open/template install, group management, and script/template CRUD; adjust helper visibilities and routing for v2 command handling.
Workspace hierarchy & manager
Sources/Workspace.swift, Sources/WorkspaceGroupManager.swift, Sources/WorkspaceGroup.swift, Sources/TabManager.swift
Introduce parent-child IDs, collapse state, startup command fields, groupManager integration, visibleWorkspaces ordering, move/indent/outdent, modified add/open/restore snapshot logic and session persistence fields.
Template system & parser
Sources/TemplateRepository.swift, Sources/TemplateYamlParser.swift, Sources/TemplateManagerViewModel.swift, Sources/TemplateManagerWindow.swift, Sources/TemplateManagerHelpSidebar.swift
Add file-backed template storage under ~/.config, YAML tree parser/serializer, management UI, view model, help sidebar and seeding.
Script system & UI
Sources/ScriptRepository.swift, Sources/ScriptManagerViewModel.swift, Sources/ScriptManagerWindow.swift, Sources/ScriptsMenu.swift, Sources/StartupScriptRunner.swift
Add script repo, manager UI, Scripts menu, startup-run scheduling (prompt-ready callbacks, fallback timer), and script execution plumbing.
Sidebar UI, drop & context menus
Sources/ContentView.swift, Sources/TabItemDisplaySnapshot.swift, Sources/SidebarContextMenuHost.swift, Sources/SidebarContextMenuController.swift, Sources/SidebarContextMenuActions.swift, Sources/GroupHeaderView.swift, Sources/GroupItem.swift
Rework sidebar to render grouped/indented items, chevrons, collapse/expand, group-aware drag/drop and reorder logic, context menu host/controller/actions, and display snapshot publisher.
Terminal & panels
Sources/Panels/TerminalPanel.swift, Sources/TerminalControllerGroupCommands.swift
Add sendInteractiveText via key events mapping, and group-command handlers that serialize workspace trees for v2 responses.
Editor, dialogs & utilities
Sources/MonospaceTextEditor.swift, Sources/NewWorkspaceDialog.swift, Sources/NameSanitizer.swift
Add monospace NSTextView wrapper, new-workspace open panel with template popup, and filename sanitizer with traversal/separator rejection.
Session persistence & snapshots
Sources/SessionPersistence.swift, Sources/SessionWorkspaceGroupSnapshot.swift
Extend snapshot models to include child indices, collapse state, startupCommand, top-level indices and new sidebar layout/group snapshots.
App integration & services
Sources/AppDelegate.swift, Sources/cmuxApp.swift, Resources/Info.plist, Update/UpdateTitlebarAccessory.swift
Register Finder Services handlers (openInCmux*), seed scripts/templates at launch, add Scripts menu, change titlebar new-tab action to workspace dialog, and update Info.plist service entries and local network usage description.
Tests & build
cmuxTests/*, GhosttyTabs.xcodeproj/project.pbxproj
Add comprehensive unit tests for config parsing, name sanitization, script/template repos, session/group persistence and workspace group manager; add many new Swift files to Xcode project build phases.
Misc UI & workspace model
Sources/Workspace.swift, Sources/TabItemDisplaySnapshot.swift
Add focused-panel prompt/menu state, onPromptReady callbacks, display snapshot computation and Equatable snapshot struct to decouple view updates.

Sequence Diagram(s)

sequenceDiagram
    participant Finder as User / Finder
    participant CLI as CLI (`cmux open`)
    participant App as AppDelegate
    participant Socket as v2 Socket Server
    participant TM as TabManager / WorkspaceGroupManager
    participant Template as TemplateRepository
    participant Script as StartupScriptRunner
    participant UI as Sidebar / TerminalPanel

    Finder->>CLI: invoke `cmux open /path`
    CLI->>Socket: attempt connect & send `project.open(path)`
    alt socket reachable
        Socket->>TM: v2ProjectOpen handling
        TM->>Template: try parse .cmux.yaml
        alt config parsed
            Template-->>TM: parsed tab/group defs
            TM->>TM: create parent and child workspaces, register hierarchy
            TM->>Script: schedule startup scripts (onPromptReady)
        else no config
            TM->>TM: create single workspace
        end
        TM-->>Socket: return created workspace ids / dedupe flag
        Socket-->>CLI: OK
    else socket unreachable
        CLI->>App: launch app
        CLI->>CLI: wait/retry socket (up to 10s)
        CLI->>Socket: retry `project.open`
        Socket-->>CLI: OK
    end
    UI->>Script: terminal becomes prompt-ready
    Script->>UI: send interactive startup text
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 I hopped through folders, hex and YAML bright,

I nested tabs in cozy groups last night,
I seeded scripts and sent them when the prompt sang,
Chevrons fold and templates bloom—what a springtime bang!
Hop, click, open—cmux wakes with delight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: adding hierarchical workspace groups with templates and scripts functionality.
Description check ✅ Passed The PR description is comprehensive, covering summary, features, security, testing plan, and includes well-formatted sections matching the template structure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/hierarchical-workspace-groups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds hierarchical workspace groups (parent-child relationships, up to 3 levels deep), YAML-based project templates, shell scripts, a cmux open /path CLI command, session persistence for the new hierarchy, and matching socket API commands. The overall architecture is well-designed — WorkspaceGroupManager cleanly separates hierarchy state from TabManager, path-traversal sanitization is solid and thoroughly tested, and backward-compatible session restore handles both old group-based layouts and the new parent-child model.

Key issues found:

  • Depth limit not enforced in template expansion: createTemplateChildren (TabManager) and createChildWorkspaces (TerminalControllerProjectCommands) recurse through template trees without checking the 3-level max-depth invariant. A YAML template with 4+ nesting levels will silently create workspaces beyond the enforced limit.
  • v2GroupCollapse/v2GroupExpand bypass hasChildren guard: These socket API handlers set ws.isCollapsed directly, skipping the check that WorkspaceGroupManager.toggleCollapsed enforces — collapsing a leaf workspace is inconsistent state.
  • Silent .cmux.yaml parse error: When a project config file exists but fails to parse, the error is discarded and the project opens as a plain workspace with no user-visible feedback.
  • seedDefaultTemplates bypasses NameSanitizer: Inconsistent with every other path construction in TemplateRepository; should delegate to saveTemplate(named:rawYaml:).
  • Misleading StartupScriptRunner class comment: Claims startup scripts are not run on session restore, but TabManager explicitly re-runs them — the comment needs to reflect the actual intent.

Confidence Score: 4/5

  • PR is close to merge-ready; one P1 depth-limit bypass should be fixed before shipping to avoid invariant violations from crafted templates.
  • The bulk of the code is well-structured with good test coverage (name sanitization, group manager, session persistence). The security hardening (path traversal, socket auth tightening) is solid. The main blocking concern is createTemplateChildren / createChildWorkspaces not enforcing the 3-level depth limit — this is a stated invariant that can be silently violated by any user-authored template YAML. The remaining P2s are style/consistency issues. Score rewards the extensive testing and clean architecture while flagging the one concrete functional gap.
  • Sources/TabManager.swift (createTemplateChildren depth check) and Sources/TerminalControllerProjectCommands.swift (createChildWorkspaces depth check and silent parse error).

Important Files Changed

Filename Overview
Sources/TabManager.swift Adds parent-child workspace management, template expansion, hierarchy-aware drag-and-drop, and session persistence. createTemplateChildren recurses without checking the 3-level depth invariant, potentially creating workspaces beyond max depth from template YAML.
Sources/WorkspaceGroupManager.swift New manager for parent-child workspace hierarchy. Depth checks, indent/outdent, collapse/expand, and visible-workspace traversal are all correctly implemented with appropriate guards.
Sources/NameSanitizer.swift New path-traversal sanitizer; correctly rejects /, \, :, and ... Well-tested with 15+ test cases covering all rejection vectors and integration tests for both repos.
Sources/TerminalController.swift Adds group/project/script/template socket API routes and tightens socket auth (cmuxOnly mode now rejects if LOCAL_PEERPID unavailable). Widens v2MainSync, V2CallResult, and v2ResolveTabManager from private to internal to support new extension files.
Sources/TerminalControllerGroupCommands.swift New file implementing group socket API. v2GroupCollapse sets ws.isCollapsed = true without checking ws.hasChildren, inconsistently bypassing the guard present in WorkspaceGroupManager.toggleCollapsed.
Sources/TerminalControllerProjectCommands.swift New file for project.open and project.open_template socket commands. .cmux.yaml parse errors are silently swallowed, giving no feedback when a config file is malformed. Recursive child creation also lacks depth enforcement.
Sources/TemplateYamlParser.swift Custom YAML parser for workspace templates. No depth limit enforced during parse; parser will happily return arbitrarily deep trees. Depth validation depends entirely on the consumer. extractYamlValue strips quotes but does not handle YAML escape sequences.
Sources/TemplateRepository.swift Template CRUD over ~/.config/cmux/templates/. All user-facing methods correctly sanitize names. seedDefaultTemplates bypasses NameSanitizer, directly appending the name to the path — inconsistent with every other method in the file.
Sources/StartupScriptRunner.swift Schedules startup commands via onPromptReady with a 5-second fallback. Class comment says scripts are NOT run on restore, but TabManager explicitly runs startup commands on restore — the comment is misleading.
cmuxTests/WorkspaceGroupManagerTests.swift Good coverage of the group manager: depth queries, indent/outdent, collapse/expand, visible workspaces, and descendant collection. Tests are clear and correctly verify invariants.

Sequence Diagram

sequenceDiagram
    participant CLI as cmux CLI
    participant Socket as Socket API
    participant TC as TerminalController
    participant TM as TabManager
    participant GM as WorkspaceGroupManager
    participant WS as Workspace

    CLI->>Socket: project.open { path }
    Socket->>TC: v2ProjectOpen(params)
    TC->>TC: check .cmux.yaml / dedupe
    TC->>TM: addWorkspace(workingDirectory:)
    TM->>GM: registerWorkspaceAsStandalone(id)
    TC->>TM: addWorkspace(skipStandaloneRegistration: true) [children]
    TM-->>TC: child Workspace
    TC->>GM: addChildId(childId, to: parentId)
    TC->>WS: startupCommand = command
    TC->>TC: scriptRunner.scheduleCommand(command, workspace:, panelId:)
    WS-->>WS: panelDidUpdateShellActivityState(.promptIdle)
    WS->>WS: onPromptReady[panelId]() → panel.sendInteractiveText(command)

    Note over TM,GM: Session Restore
    TM->>WS: childWorkspaceIds = restored indices
    TM->>GM: items = topLevelWorkspaceIndices
    TM->>TC: scriptRunner.scheduleCommand (re-run startup commands)
Loading

Reviews (1): Last reviewed commit: "feat: add hierarchy-aware drag-and-drop ..." | Re-trigger Greptile

Comment thread Sources/TabManager.swift
Comment on lines +1941 to +1950
if !child.children.isEmpty {
createTemplateChildren(
child.children,
parentId: ws.id,
workingDirectory: workingDirectory,
scriptRunner: scriptRunner
)
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Depth limit not enforced in createTemplateChildren

createTemplateChildren recurses into arbitrarily deep template hierarchies without checking the max-depth invariant (3 levels). groupManager.addChildId just does parent.childWorkspaceIds.append(childId) — no depth gate. A template YAML with 4+ nesting levels will create workspaces at depth 4+, silently violating the invariant documented on WorkspaceGroupManager.

The interactive path enforces the limit via addChildWorkspace, which guards on depth < 3, but this private helper bypasses that entirely. The recursive v2ProjectOpenTemplate helper in TerminalControllerProjectCommands.swift has the same issue.

Consider adding a depth guard before each recursive call:

if !child.children.isEmpty {
    let newDepth = groupManager.depthOf(workspaceId: ws.id)
    if newDepth < 3 {
        createTemplateChildren(
            child.children,
            parentId: ws.id,
            workingDirectory: workingDirectory,
            scriptRunner: scriptRunner
        )
    }
}

Comment on lines +70 to +71
ws.isCollapsed = true
tm.items = tm.groupManager.items

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 v2GroupCollapse/v2GroupExpand bypass the hasChildren guard

toggleCollapsed in WorkspaceGroupManager checks ws.hasChildren before touching isCollapsed. These socket API handlers set ws.isCollapsed = true/false directly, so they will mark a leaf workspace as collapsed/expanded — inconsistent state that other code inspects (e.g. visibleWorkspaces checks ws.hasChildren && !ws.isCollapsed).

For v2GroupCollapse, consider:

Suggested change
ws.isCollapsed = true
tm.items = tm.groupManager.items
ws.isCollapsed = ws.hasChildren ? true : ws.isCollapsed
tm.items = tm.groupManager.items

And likewise for v2GroupExpand:

Suggested change
ws.isCollapsed = true
tm.items = tm.groupManager.items
ws.isCollapsed = false
tm.items = tm.groupManager.items

The expand case is harmless, but the collapse case should at minimum skip the mutation for childless workspaces.

Comment on lines +99 to +106
func seedDefaultTemplates() {
for (name, content) in Self.defaultTemplates {
guard !hasTemplate(named: name) else { continue }
try? ensureDirectoryExists()
let path = directory.appendingPathComponent("\(name).yaml")
try? content.write(to: path, atomically: true, encoding: .utf8)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 seedDefaultTemplates bypasses NameSanitizer

Every other method in TemplateRepository routes the name through NameSanitizer.sanitize before building the path. seedDefaultTemplates appends the name directly:

let path = directory.appendingPathComponent("\(name).yaml")

The current default names are safe, but this is an inconsistency — if a future default template name ever contained a problematic character, there would be no validation. Use saveTemplate(named:rawYaml:) instead, which already handles sanitization and directory creation:

Suggested change
func seedDefaultTemplates() {
for (name, content) in Self.defaultTemplates {
guard !hasTemplate(named: name) else { continue }
try? ensureDirectoryExists()
let path = directory.appendingPathComponent("\(name).yaml")
try? content.write(to: path, atomically: true, encoding: .utf8)
}
}
func seedDefaultTemplates() {
for (name, content) in Self.defaultTemplates {
guard !hasTemplate(named: name) else { continue }
try? saveTemplate(named: name, rawYaml: content)
}
}

Comment on lines +68 to +70
} catch {
// Fall through to single-workspace fallback on parse error
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent .cmux.yaml parse error discards user-visible failure

When a .cmux.yaml file exists but fails to parse, the error is silently swallowed and the command falls through to create a plain single-workspace. From the user's perspective the project appears to open normally, with no indication that their config file is broken.

At a minimum, the error should be surfaced in the response or logged:

} catch {
    // Fall through to single-workspace fallback on parse error
    // TODO: surface parse error to the caller so the user knows
    // their .cmux.yaml has a problem.
    // return .err(code: "config_parse_error", message: error.localizedDescription, data: nil)
}

Consider returning a warning field in the success response, e.g. "config_warning": error.localizedDescription, so callers can display it without breaking the open flow.

Comment on lines +4 to +5
/// Scripts are NOT run during session restore to avoid duplicate execution.
@MainActor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Class-level doc comment contradicts actual session-restore behavior

The class comment says:

Scripts are NOT run during session restore to avoid duplicate execution.

But TabManager explicitly calls scriptRunner.scheduleCommand(command, workspace:, panelId:) for every workspace with a startupCommand during restoreFromSnapshot — which IS session restore. The PR description also confirms this is intentional ("re-run on session restore"). The stale comment will mislead future readers.

Suggested change
/// Scripts are NOT run during session restore to avoid duplicate execution.
@MainActor
/// Manages startup script/command execution for workspaces created from project configs or templates.
/// Startup *commands* are re-run on session restore (`startupCommand` field).
/// File-based scripts fetched from `ScriptRepository` respect the `shouldRunScript(isRestore:)` gate.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

27 issues found across 43 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/TerminalControllerProjectCommands.swift">

<violation number="1" location="Sources/TerminalControllerProjectCommands.swift:260">
P2: Project dedupe uses `currentDirectory`, which is a mutable runtime value updated as users `cd`; this can fail to match an existing project root and create duplicate workspaces.</violation>
</file>

<file name="Sources/GroupHeaderView.swift">

<violation number="1" location="Sources/GroupHeaderView.swift:7">
P2: Functional `Color(hex:)` parsing was added to a file marked as temporary/cleanup-only, creating hidden coupling and future regression risk if that placeholder file is removed.</violation>
</file>

<file name="Sources/ScriptsMenu.swift">

<violation number="1" location="Sources/ScriptsMenu.swift:52">
P2: Run Script action lacks a live prompt-state guard and may send script text when the terminal is no longer at prompt.</violation>
</file>

<file name="Sources/ScriptManagerViewModel.swift">

<violation number="1" location="Sources/ScriptManagerViewModel.swift:69">
P2: Unsaved edits can be lost when add/duplicate/reload/select actions call loadScript without checking isDirty, overwriting editorText and resetting the dirty flag.</violation>

<violation number="2" location="Sources/ScriptManagerViewModel.swift:118">
P1: Script load failures are silently coerced to empty content, masking read errors and risking accidental overwrite.</violation>
</file>

<file name="Sources/cmuxApp.swift">

<violation number="1" location="Sources/cmuxApp.swift:984">
P2: activeTabManager no longer synchronizes the active main-window context, so menu actions can target a stale/root TabManager in multi-window sessions.</violation>
</file>

<file name="Sources/StartupScriptRunner.swift">

<violation number="1" location="Sources/StartupScriptRunner.swift:46">
P2: Startup scripts can be sent to the currently focused terminal instead of the scheduled panelId if focus changes before the prompt-ready/fallback callback fires.</violation>
</file>

<file name="Sources/AppDelegate.swift">

<violation number="1" location="Sources/AppDelegate.swift:11376">
P2: Finder service selects the target window via listMainWindowSummaries().first, which is built from Dictionary values and therefore unordered. With multiple windows open, this can route Finder service opens to an arbitrary background window instead of the key/main window.</violation>

<violation number="2" location="Sources/AppDelegate.swift:11383">
P2: Finder Services return immediately after creating a cold-start window, so only the first selected item is opened and template services never apply the template on cold start.</violation>
</file>

<file name="Sources/TemplateYamlParser.swift">

<violation number="1" location="Sources/TemplateYamlParser.swift:217">
P2: Legacy `startupScript` is parsed but discarded when building `TemplateNode` children, so legacy templates lose their startup commands during migration/serialization.</violation>
</file>

<file name="cmuxTests/WorkspaceGroupManagerTests.swift">

<violation number="1" location="cmuxTests/WorkspaceGroupManagerTests.swift:213">
P2: Depth-constraint test does not execute or assert the blocked-indent path, so max-depth enforcement is not actually validated.</violation>
</file>

<file name="Sources/TemplateManagerWindow.swift">

<violation number="1" location="Sources/TemplateManagerWindow.swift:73">
P2: Unsaved-changes flow closes or switches immediately after save without checking for save failure, so a failed save can still discard the user’s edits/context.</violation>
</file>

<file name="Sources/TerminalControllerGroupCommands.swift">

<violation number="1" location="Sources/TerminalControllerGroupCommands.swift:141">
P2: v2GroupAddWorkspace reports max_depth for any addChildWorkspace failure, including missing parent workspaces, which misleads API clients about the actual error.</violation>
</file>

<file name="Sources/ScriptManagerWindow.swift">

<violation number="1" location="Sources/ScriptManagerWindow.swift:41">
P2: Calling viewModel.reload() unconditionally when showing the window can discard unsaved edits if the user reopens the Script Manager while it’s already open. There’s no dirty-state guard or prompt in show(), so in-memory edits may be overwritten by disk content.</violation>

<violation number="2" location="Sources/ScriptManagerWindow.swift:118">
P2: `pendingSelection` drives the List selection, but add/duplicate mutate `selectedName` without updating `pendingSelection`. After add/duplicate, the editor switches to the new script while the list selection stays on the old item, and later selection-change prompts can be out of sync.</violation>
</file>

<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:5090">
P2: onPromptReady callbacks are only removed on prompt-idle, but panel close paths clear other per-panel maps without clearing onPromptReady. Closing a panel before prompt-idle leaves stale retained closures.</violation>
</file>

<file name="Sources/WorkspaceGroupManager.swift">

<violation number="1" location="Sources/WorkspaceGroupManager.swift:31">
P2: `addChildId` bypasses documented hierarchy invariants by appending children without depth/structure validation, while callers rely on it for max-depth enforcement.</violation>
</file>

<file name="Sources/CmuxConfigParser.swift">

<violation number="1" location="Sources/CmuxConfigParser.swift:121">
P2: Top-level tabs parsing is not restricted to root indentation, so nested group `tabs:` blocks can be misparsed as root tabs.</violation>

<violation number="2" location="Sources/CmuxConfigParser.swift:233">
P2: Group-level workingDirectory is parsed but never applied to group tab definitions, so the YAML workingDirectory for a group has no effect.</violation>

<violation number="3" location="Sources/CmuxConfigParser.swift:235">
P2: Nested groups are always flagged as max-depth exceeded because parseGroupsList never uses currentDepth or recurses into child groups, so valid depth-2 hierarchies are rejected.</violation>
</file>

<file name="Sources/ContentView.swift">

<violation number="1" location="Sources/ContentView.swift:8369">
P2: TabItemView now receives a visible index, but move/selection logic still treats `index` as a flat `tabManager.tabs` index. When groups are collapsed, visible indices can diverge from flat order, so moveBy and shift-selection can reorder/select the wrong workspaces.</violation>
</file>

<file name="Sources/TerminalControllerScriptTemplateCommands.swift">

<violation number="1" location="Sources/TerminalControllerScriptTemplateCommands.swift:66">
P2: `v2TemplateGet` incorrectly collapses all repository errors into `not_found`, masking parse and I/O failures as missing templates.</violation>

<violation number="2" location="Sources/TerminalControllerScriptTemplateCommands.swift:116">
P2: Template deserialization is unbounded recursive on untrusted request data, allowing excessively deep/large trees to cause stack/memory/CPU exhaustion.</violation>
</file>

<file name="CLI/cmux.swift">

<violation number="1" location="CLI/cmux.swift:2515">
P2: `openProjectPath` bypasses socket authentication, so `cmux open` can fail in password-protected socket mode.</violation>
</file>

<file name="Sources/TabManager.swift">

<violation number="1" location="Sources/TabManager.swift:1941">
P2: Template import recursively creates child workspaces without enforcing the documented 3-level hierarchy limit, so deep templates can bypass the depth constraint enforced elsewhere (e.g., addChildWorkspace/drag-drop).</violation>

<violation number="2" location="Sources/TabManager.swift:2315">
P1: Closing a parent workspace can delete children and then abort closing the parent due to the post-recursion minimum-tab guard.</violation>

<violation number="3" location="Sources/TabManager.swift:5234">
P2: Legacy/no-layout snapshot restore rebuilds sidebar layout by registering workspaces without clearing WorkspaceGroupManager.items, so stale IDs from the pre-restore state can persist and mix with restored IDs. This can leave orphan/duplicate sidebar entries when topLevelWorkspaceIndices is absent.</violation>
</file>

Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

// MARK: - Private

private func loadScript(named name: String) {
editorText = repo.getScript(named: name) ?? ""

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Script load failures are silently coerced to empty content, masking read errors and risking accidental overwrite.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ScriptManagerViewModel.swift, line 118:

<comment>Script load failures are silently coerced to empty content, masking read errors and risking accidental overwrite.</comment>

<file context>
@@ -0,0 +1,122 @@
+    // MARK: - Private
+
+    private func loadScript(named name: String) {
+        editorText = repo.getScript(named: name) ?? ""
+        loadedText = editorText
+        isDirty = false
</file context>
Fix with Cubic

Comment thread Sources/TabManager.swift
}

// After closing children, recheck count
guard tabs.count > 1 else { return }

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Closing a parent workspace can delete children and then abort closing the parent due to the post-recursion minimum-tab guard.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TabManager.swift, line 2315:

<comment>Closing a parent workspace can delete children and then abort closing the parent due to the post-recursion minimum-tab guard.</comment>

<file context>
@@ -2090,6 +2302,18 @@ class TabManager: ObservableObject {
+        }
+
+        // After closing children, recheck count
+        guard tabs.count > 1 else { return }
+
         sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
</file context>
Fix with Cubic

for wsId in tm.items {
guard let ws = tm.workspace(for: wsId),
ws.hasChildren else { continue }
let wsPath = URL(fileURLWithPath: ws.currentDirectory)

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Project dedupe uses currentDirectory, which is a mutable runtime value updated as users cd; this can fail to match an existing project root and create duplicate workspaces.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TerminalControllerProjectCommands.swift, line 260:

<comment>Project dedupe uses `currentDirectory`, which is a mutable runtime value updated as users `cd`; this can fail to match an existing project root and create duplicate workspaces.</comment>

<file context>
@@ -0,0 +1,271 @@
+            for wsId in tm.items {
+                guard let ws = tm.workspace(for: wsId),
+                      ws.hasChildren else { continue }
+                let wsPath = URL(fileURLWithPath: ws.currentDirectory)
+                    .resolvingSymlinksInPath().path
+                if wsPath == canonicalPath {
</file context>
Fix with Cubic


import SwiftUI

extension Color {

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Functional Color(hex:) parsing was added to a file marked as temporary/cleanup-only, creating hidden coupling and future regression risk if that placeholder file is removed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/GroupHeaderView.swift, line 7:

<comment>Functional `Color(hex:)` parsing was added to a file marked as temporary/cleanup-only, creating hidden coupling and future regression risk if that placeholder file is removed.</comment>

<file context>
@@ -0,0 +1,17 @@
+
+import SwiftUI
+
+extension Color {
+    init?(hex: String) {
+        let hex = hex.trimmingCharacters(in: .init(charactersIn: "#"))
</file context>
Fix with Cubic

Comment thread Sources/ScriptsMenu.swift
let color = dict["color"] as? String
let command = dict["command"] as? String
let childDicts = dict["children"] as? [[String: Any]] ?? []
let children = childDicts.map { deserializeTemplateNode($0) }

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Template deserialization is unbounded recursive on untrusted request data, allowing excessively deep/large trees to cause stack/memory/CPU exhaustion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TerminalControllerScriptTemplateCommands.swift, line 116:

<comment>Template deserialization is unbounded recursive on untrusted request data, allowing excessively deep/large trees to cause stack/memory/CPU exhaustion.</comment>

<file context>
@@ -0,0 +1,119 @@
+        let color = dict["color"] as? String
+        let command = dict["command"] as? String
+        let childDicts = dict["children"] as? [[String: Any]] ?? []
+        let children = childDicts.map { deserializeTemplateNode($0) }
+        return TemplateNode(title: title, color: color, command: command, children: children)
+    }
</file context>
Fix with Cubic

let result = serializeTemplateNode(template.root)
return .ok(["name": name, "root": result])
} catch {
return .err(code: "not_found", message: "Template '\(name)' not found", data: nil)

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: v2TemplateGet incorrectly collapses all repository errors into not_found, masking parse and I/O failures as missing templates.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TerminalControllerScriptTemplateCommands.swift, line 66:

<comment>`v2TemplateGet` incorrectly collapses all repository errors into `not_found`, masking parse and I/O failures as missing templates.</comment>

<file context>
@@ -0,0 +1,119 @@
+            let result = serializeTemplateNode(template.root)
+            return .ok(["name": name, "root": result])
+        } catch {
+            return .err(code: "not_found", message: "Template '\(name)' not found", data: nil)
+        }
+    }
</file context>
Fix with Cubic

Comment thread CLI/cmux.swift
throw CLIError(message: "Path does not exist: \(resolved)")
}

let client = SocketClient(path: socketPath)

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: openProjectPath bypasses socket authentication, so cmux open can fail in password-protected socket mode.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CLI/cmux.swift, line 2515:

<comment>`openProjectPath` bypasses socket authentication, so `cmux open` can fail in password-protected socket mode.</comment>

<file context>
@@ -2490,6 +2497,45 @@ struct CMUXCLI {
+            throw CLIError(message: "Path does not exist: \(resolved)")
+        }
+
+        let client = SocketClient(path: socketPath)
+        if (try? client.connect()) == nil {
+            client.close()
</file context>
Fix with Cubic

Comment thread Sources/TabManager.swift
}
}

if !child.children.isEmpty {

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Template import recursively creates child workspaces without enforcing the documented 3-level hierarchy limit, so deep templates can bypass the depth constraint enforced elsewhere (e.g., addChildWorkspace/drag-drop).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TabManager.swift, line 1941:

<comment>Template import recursively creates child workspaces without enforcing the documented 3-level hierarchy limit, so deep templates can bypass the depth constraint enforced elsewhere (e.g., addChildWorkspace/drag-drop).</comment>

<file context>
@@ -1817,6 +1862,93 @@ class TabManager: ObservableObject {
+                }
+            }
+
+            if !child.children.isEmpty {
+                createTemplateChildren(
+                    child.children,
</file context>
Fix with Cubic

Comment thread Sources/TabManager.swift
tabs = newTabs
selectedTabId = newSelectedId

// Rebuild sidebar layout from snapshot

@cubic-dev-ai cubic-dev-ai Bot Mar 25, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Legacy/no-layout snapshot restore rebuilds sidebar layout by registering workspaces without clearing WorkspaceGroupManager.items, so stale IDs from the pre-restore state can persist and mix with restored IDs. This can leave orphan/duplicate sidebar entries when topLevelWorkspaceIndices is absent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/TabManager.swift, line 5234:

<comment>Legacy/no-layout snapshot restore rebuilds sidebar layout by registering workspaces without clearing WorkspaceGroupManager.items, so stale IDs from the pre-restore state can persist and mix with restored IDs. This can leave orphan/duplicate sidebar entries when topLevelWorkspaceIndices is absent.</comment>

<file context>
@@ -4963,10 +5212,41 @@ extension TabManager {
         tabs = newTabs
         selectedTabId = newSelectedId
+
+        // Rebuild sidebar layout from snapshot
+        if let topLevelIndices = snapshot.topLevelWorkspaceIndices {
+            groupManager.items = topLevelIndices.compactMap { index in
</file context>
Fix with Cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

🟠 Major comments (21)
CLI/cmux.swift-2515-2523 (1)

2515-2523: ⚠️ Potential issue | 🟠 Major

openProjectPath skips socket authentication.

This helper connects and immediately sends project.open, but never calls authenticateClientIfNeeded. On authenticated sockets, cmux open ... will fail even though the rest of the CLI already resolves --password / saved passwords via connectClient(...).

🔐 Suggested fix
-    private func openProjectPath(_ path: String, socketPath: String, windowId: String?) throws {
+    private func openProjectPath(
+        _ path: String,
+        socketPath: String,
+        windowId: String?,
+        explicitPassword: String?
+    ) throws {
         let resolved = resolvePath(path)
         var isDir: ObjCBool = false
         let exists = FileManager.default.fileExists(atPath: resolved, isDirectory: &isDir)
@@
-        let client = SocketClient(path: socketPath)
-        if (try? client.connect()) == nil {
-            client.close()
-            try launchApp()
-            let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10)
-            defer { launchedClient.close() }
-            var params: [String: Any] = ["path": directory]
-            if let windowId { params["window_id"] = windowId }
-            let response = try launchedClient.sendV2(method: "project.open", params: params)
-            let groupId = (response["group_id"] as? String) ?? ""
-            if !groupId.isEmpty { print("OK \(groupId)") }
-            try activateApp()
-            return
-        }
+        let client = try connectClient(
+            socketPath: socketPath,
+            explicitPassword: explicitPassword,
+            launchIfNeeded: true
+        )
         defer { client.close() }
 
         var params: [String: Any] = ["path": directory]
         if let windowId { params["window_id"] = windowId }
         let response = try client.sendV2(method: "project.open", params: params)
         let groupId = (response["group_id"] as? String) ?? ""
         if !groupId.isEmpty { print("OK \(groupId)") }
         try activateApp()
     }

Callers at Lines 1409 and 1415 should pass socketPasswordArg.

Also applies to: 2531-2533

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 2515 - 2523, The openProjectPath helper is
creating a SocketClient and sending "project.open" without performing
authentication, so update openProjectPath to call authenticateClientIfNeeded (or
reuse connectClient) after obtaining the connected SocketClient (both for the
initial client.connect() path and the launchedClient from
SocketClient.waitForConnectableSocket) and before calling sendV2; ensure callers
that invoke openProjectPath (they currently pass socketPasswordArg at the call
sites mentioned) forward the socketPasswordArg through so
authenticateClientIfNeeded has the password to authenticate the socket.
CLI/cmux.swift-1406-1410 (1)

1406-1410: ⚠️ Potential issue | 🟠 Major

Parse open flags before selecting the path.

This branch runs before subcommand-help handling and never parses its own args, so cmux open --help tries to open a literal --help path, and cmux open /repo --window ... silently drops the trailing --window. Parse commandArgs here (or move this block below help dispatch) before picking the path.

💡 Suggested direction
-        // Explicit `open` command: cmux open /path/to/folder [--window <id>]
         if command == "open" {
-            let openPath = commandArgs.first ?? "."
-            try openProjectPath(openPath, socketPath: resolvedSocketPath, windowId: windowId)
+            if commandArgs.contains("--help") || commandArgs.contains("-h") {
+                print(subcommandUsage("open") ?? "Usage: cmux open <path> [--window <id>]")
+                return
+            }
+            let (openWindowOpt, rem0) = parseOption(commandArgs, name: "--window")
+            let positional = rem0.filter { $0 != "--" }
+            if let extra = positional.dropFirst().first {
+                throw CLIError(message: "open: unexpected argument '\(extra)'")
+            }
+            let openPath = positional.first ?? "."
+            try openProjectPath(
+                openPath,
+                socketPath: resolvedSocketPath,
+                windowId: openWindowOpt ?? windowId
+            )
             return
         }

You’ll also want a matching subcommandUsage("open") / usage() entry so the help path stays discoverable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CLI/cmux.swift` around lines 1406 - 1410, The open-branch handles "command ==
\"open\"" before parsing flags so commandArgs like "--help" or "--window" are
treated as the path; change this to parse the open subcommand's flags from
commandArgs first (or move the block below the global help dispatch), extract
the path only after flag parsing, and then call openProjectPath(openPath,
socketPath: resolvedSocketPath, windowId: windowId); also add a matching
subcommandUsage("open") (and a usage() entry) so "cmux open --help" shows the
open help instead of attempting to open a literal path.
Sources/CmuxConfigParserGroups.swift-49-53 (1)

49-53: ⚠️ Potential issue | 🟠 Major

Nested list lines can be misparsed as new tabs.

On Line 49, any - starts a new tab entry regardless of indentation. That breaks valid nested lists within a tab block (e.g., commands arrays), because deeper-indented - ... lines get flushed into separate tabs.

Suggested fix
     var tabsSectionIndent = 0
     var entryIndent = 0
+    var tabItemIndent: Int?
     var currentEntryLines: [String] = []

@@
-            if trimmed.hasPrefix("- ") {
+            if trimmed.hasPrefix("- "), (tabItemIndent == nil || indent == tabItemIndent) {
+                if tabItemIndent == nil { tabItemIndent = indent }
                 flushEntry()
                 currentEntryLines.append(String(trimmed.dropFirst(2)))
                 entryIndent = indent
             } else if indent > entryIndent {
                 currentEntryLines.append(trimmed)
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfigParserGroups.swift` around lines 49 - 53, The parser
currently starts a new tab whenever a line begins with "- " regardless of
indentation; update the conditional around trimmed.hasPrefix("- ") in the block
that calls flushEntry() so it only starts a new entry when the dash is at the
same indentation level as the existing entry (e.g., require indent ==
entryIndent or otherwise ensure indent is not greater than entryIndent) to avoid
treating deeper-indented nested list items as new tabs; adjust the condition
that uses trimmed, indent, entryIndent and the flushEntry()/currentEntryLines
logic accordingly.
Sources/AppDelegate.swift-11400-11412 (1)

11400-11412: ⚠️ Potential issue | 🟠 Major

The dedupe check misses most already-open workspaces.

This loop only scans tm.items and immediately skips anything without children. Reopening an existing plain workspace—or even selecting two files from the same directory—still creates duplicates. The lookup needs to scan every workspace across allTabManagers().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 11400 - 11412, The dedupe loop only
iterates tm.items and skips workspaces without children (ws.hasChildren), so
plain workspaces are missed; change the lookup to iterate every TabManager from
allTabManagers() and examine each manager's items (calling workspace(for:) on
each) comparing their resolved currentDirectory to canonicalPath, and when found
set tm.selectedTabId = ws.id and break; ensure you remove the ws.hasChildren
guard so plain workspaces are considered.
Sources/AppDelegate.swift-11379-11383 (1)

11379-11383: ⚠️ Potential issue | 🟠 Major

Cold-start bootstrap returns too early.

When no window exists, both branches return immediately after creating the first window. openInCmux drops the rest of the selection, and openInCmuxBuilder/Fixer also skips tm.openTemplate(...) for that first cold-start open.

Also applies to: 11451-11458

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 11379 - 11383, The cold-start branch
returns immediately after creating the first window, causing
openInCmux/openInCmuxBuilder/openInCmuxFixer to drop remaining items and skip
tm.openTemplate for the first item; update the logic around
resolvedDirectoryPath(for:), createMainWindow(initialWorkingDirectory:), and the
surrounding conditional so you do not return after creating the initial
window—instead allow the method to continue processing the full items collection
(call openInCmux / openInCmuxBuilder / openInCmuxFixer for each item and ensure
tm.openTemplate(...) is invoked where appropriate) so the rest of the selection
is handled and templates run on cold start.
Sources/AppDelegate.swift-11365-11372 (1)

11365-11372: ⚠️ Potential issue | 🟠 Major

Mark Finder-service opens as explicit startup intents.

Unlike the other external-open entry points in this file, these handlers never call prepareForExplicitOpenIntentAtStartup(). On a cold launch, registerMainWindow(...) can still restore the previous session and overwrite the folder/template the service was asked to open.

Minimal fix
 `@objc` func openInCmux(
     _ pasteboard: NSPasteboard,
     userData: String,
     error: AutoreleasingUnsafeMutablePointer<NSString>
 ) {
+    prepareForExplicitOpenIntentAtStartup()
     guard let items = pasteboard.readObjects(forClasses: [NSURL.self], options: [
         .urlReadingFileURLsOnly: true
     ]) as? [URL] else { return }
 private func openInCmuxWithTemplate(
     _ pasteboard: NSPasteboard,
     templateName: String,
     error: AutoreleasingUnsafeMutablePointer<NSString>
 ) {
+    prepareForExplicitOpenIntentAtStartup()
     guard let items = pasteboard.readObjects(forClasses: [NSURL.self], options: [
         .urlReadingFileURLsOnly: true
     ]) as? [URL] else { return }

Also applies to: 11442-11449

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/AppDelegate.swift` around lines 11365 - 11372, The openInCmux handler
(and the other Finder-service handler at the nearby block) must mark these as
explicit startup intents by calling prepareForExplicitOpenIntentAtStartup()
before any session-restore or window registration occurs; modify openInCmux (and
the similar handler around lines 11442-11449) to invoke
prepareForExplicitOpenIntentAtStartup() at the start of the method (before
reading the pasteboard or calling registerMainWindow/any session restore logic)
so the explicit open intent wins over restored state.
Sources/CmuxConfigParser.swift-120-124 (1)

120-124: ⚠️ Potential issue | 🟠 Major

Restrict parseTabsList to the top-level tabs: block.

Line 121 currently enters the first tabs: it sees, even when that key is nested under groups:. In configs where a group appears before the root tabs: section, those child tabs get parsed as top-level tabs and the real root section is skipped or duplicated.

🔧 Suggested fix
-                if trimmed == "\(key):" || trimmed.hasPrefix("\(key):") {
+                if indent == 0 && (trimmed == "\(key):" || trimmed.hasPrefix("\(key):")) {
                     inSection = true
                     sectionIndent = indent
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfigParser.swift` around lines 120 - 124, parseTabsList is
entering any "tabs:" it encounters including nested ones (e.g., under groups:);
restrict it to only open the top-level tabs block by checking indentation before
setting inSection/sectionIndent. Update the logic in parseTabsList (the block
that sets inSection and sectionIndent when trimmed == "\(key):" or hasPrefix) to
only set inSection = true when the indent indicates a top-level key (e.g.,
indent == 0 or matches the expected root indent) or when sectionIndent is not
already set, so nested "tabs:" under groups are ignored and only the true root
"tabs:" starts parsing.
Sources/TerminalControllerGroupCommands.swift-153-163 (1)

153-163: ⚠️ Potential issue | 🟠 Major

Don’t report success when no workspace was actually promoted.

Line 158 uses parentWorkspace(of:) as the only existence check, so Line 163 returns removed: true for three different cases: the workspace was promoted, the workspace ID does not exist, or the workspace was already top-level. Please guard tm.workspace(for: wsId) first and return a distinct result when there is no parent to remove.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalControllerGroupCommands.swift` around lines 153 - 163, The
current handler in v2MainSync returns removed: true even when the workspace
doesn't exist or is already top-level; first validate the workspace exists by
calling tm.workspace(for: wsId) (after v2ResolveTabManager) and if it returns
nil return V2CallResult.err/code or a distinct result indicating
"workspace_not_found"; then check tm.groupManager.parentWorkspace(of: wsId) and
only perform tm.groupManager.removeChildId(wsId, from: parent.id),
tm.groupManager.registerWorkspaceAsStandalone(wsId) and tm.items =
tm.groupManager.items when a parent is present; otherwise return a clear
non-success result (e.g., removed: false or a specific error code like
"no_parent") instead of .ok(["removed": true]).
Sources/TerminalControllerGroupCommands.swift-18-23 (1)

18-23: ⚠️ Potential issue | 🟠 Major

Validate and normalize color on both create and set-color.

Lines 21-22 and 127 accept raw values as-is, and v2GroupSetColor also treats a present-but-invalid color as nil. That diverges from the existing workspace color contract: malformed input should return invalid_params, and valid input should be normalized to uppercase #RRGGBB before storing.

Based on learnings, workspace color APIs in this repo must validate with WorkspaceTabColorSettings.normalizedHex(...) and reject present-but-invalid values instead of silently accepting them.

Also applies to: 114-128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalControllerGroupCommands.swift` around lines 18 - 23, The
workspace color handling must validate and normalize incoming color strings
using WorkspaceTabColorSettings.normalizedHex(...) instead of accepting raw
values; update the create handler (where ws.customColor is set) and the
v2GroupSetColor handler to (1) if "color" is present but
WorkspaceTabColorSettings.normalizedHex(color) returns nil, respond with an
invalid_params error, and (2) if it returns a value, store that normalized
uppercase "#RRGGBB" result into ws.customColor. Ensure you only reject when the
field is present and malformed (not when omitted).
Sources/ScriptsMenu.swift-31-37 (1)

31-37: ⚠️ Potential issue | 🟠 Major

Drive “Run Script” from live prompt state, and re-check it before sending.

Line 31 snapshots isFocusedPanelAtPrompt into a local during menu construction, but nothing in this view actually observes prompt changes. That means the submenu can stay incorrectly enabled/disabled after the shell state flips, and Line 52 still injects the script without a final prompt check. Please revalidate prompt readiness inside runScript(named:), and only use .disabled with state that will actually invalidate this Commands view.

Also applies to: 45-52

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ScriptsMenu.swift` around lines 31 - 37, The menu currently snapshots
isFocusedPanelAtPrompt into a local (let isAtPrompt) so the submenu enablement
can become stale; instead either bind .disabled to a state that actually updates
(e.g. a computed property or observed value from the active tab manager) or
remove the disabled guard from the static snapshot, and — critically — add a
fresh readiness check at the start of runScript(named:) that queries
Self.resolveActiveTabManager()?.selectedTab?.isFocusedPanelAtPrompt and returns
(or shows an error) if false before injecting the script; update references:
isFocusedPanelAtPrompt, runScript(named:), Menu and the .disabled usage so the
UI is reactive and the final call always revalidates the prompt state.
Sources/WorkspaceGroupManager.swift-48-55 (1)

48-55: ⚠️ Potential issue | 🟠 Major

Implement explicit cascade removal in removeWorkspace to match documented invariant.

The class invariant (line 11) states "No orphaned children — removing a parent cascades to children," but removeWorkspace only removes the workspace from items and its parent's childWorkspaceIds. It does not recursively remove descendants.

While the current code works in practice (TabManager removes from tabs first, implicitly deallocating the workspace and its children), this relies on caller discipline rather than enforcing the invariant within removeWorkspace. For robustness and proper separation of concerns, explicitly implement recursive removal:

Proposed fix for recursive removal
     /// Remove a workspace from the sidebar entirely (top-level and as child).
+    /// Also recursively removes all descendants.
     func removeWorkspace(_ workspaceId: UUID) {
+        // First, recursively remove all descendants
+        let descendants = allDescendantIds(of: workspaceId)
+        for descendantId in descendants {
+            items.removeAll { $0 == descendantId }
+        }
+        
         items.removeAll { $0 == workspaceId }
         // Also remove from any parent's childWorkspaceIds
         if let parent = parentWorkspace(of: workspaceId) {
             parent.childWorkspaceIds.removeAll { $0 == workspaceId }
         }
     }

Also add a test case for multi-level cascade: testRemoveWorkspaceCascadesToGrandchildren to verify descendants are removed from items when a parent with children is removed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceGroupManager.swift` around lines 48 - 55, removeWorkspace
currently only deletes the single workspace id from items and the immediate
parent's childWorkspaceIds, but does not enforce the class invariant "No
orphaned children" by cascading deletions; update removeWorkspace(_ workspaceId:
UUID) to recursively collect and remove all descendant workspace IDs (e.g.,
traverse childWorkspaceIds of the workspace found via parentWorkspace(of:) or a
lookup for the Workspace object) and remove them from items and their parent's
childWorkspaceIds as well, ensuring childWorkspaceIds are cleared for removed
parents; also add a unit test named testRemoveWorkspaceCascadesToGrandchildren
that creates a multi-level workspace tree and asserts all descendants are
removed from items after removing the ancestor.
Sources/ContentView.swift-10655-10657 (1)

10655-10657: ⚠️ Potential issue | 🟠 Major

Surface template read/parse failures instead of dropping the action.

Both template-open paths discard TemplateRepository.shared.getTemplate(...) errors with try?, so malformed YAML or file I/O problems look like a dead menu item. Please route the failure to visible UI instead of returning silently.

🛠️ Minimal fix
- if let template = try? TemplateRepository.shared.getTemplate(named: templateName) {
-     tabManager.openTemplate(template, directory: url.path)
- }
+ do {
+     let template = try TemplateRepository.shared.getTemplate(named: templateName)
+     tabManager.openTemplate(template, directory: url.path)
+ } catch {
+     NSAlert(error: error).runModal()
+ }

Apply the same pattern in openTemplateInWorkspace(named:).

Also applies to: 12414-12416

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10655 - 10657, The current code
swallows errors from TemplateRepository.shared.getTemplate(named:) using try?,
causing malformed YAML or I/O errors to appear as dead menu items; change both
call sites (the snippet that calls tabManager.openTemplate(template, directory:)
and openTemplateInWorkspace(named:)) to use do/try/catch, attempt let template =
try TemplateRepository.shared.getTemplate(named:), then call
tabManager.openTemplate(...) on success, and in catch route the thrown error to
the visible UI (e.g., call your app’s existing error reporting/alerting helper
or present an NSAlert with the error.localizedDescription) so failures are
surfaced instead of being ignored.
Sources/ContentView.swift-8369-8374 (1)

8369-8374: ⚠️ Potential issue | 🟠 Major

Don't pass visible-order indices into TabItemView.index.

Line 8369 now feeds visibleIndex, but TabItemView still consumes index as a flat tabManager.tabs position in the shift-range path (Line 11841) and move actions (Line 11817). Once a parent is collapsed, those orders diverge, so Shift-click and Move Up/Down can target hidden or wrong workspaces.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 8369 - 8374, TabItemView.index must
receive the absolute position in tabManager.tabs, not the visible-order
visibleIndex; compute the full index via the tab manager (e.g., let fullIndex =
tabManager.index(of: tab) or tabManager.tabs.firstIndex(where: { $0.id == tab.id
})) and pass that as index to TabItemView so its shift-range path and move
up/down actions operate on the real tab collection; keep using visibleIndex only
for UI-only things like WorkspaceShortcutMapper.commandDigitForWorkspace(at:
visibleIndex, workspaceCount: visibleCount) and leave isActive as
tabManager.selectedTabId == tab.id.
Sources/ContentView.swift-11092-11101 (1)

11092-11101: ⚠️ Potential issue | 🟠 Major

Use a real button for collapse/expand.

Image + onTapGesture is not keyboard/VoiceOver actionable, so the new hierarchy control is effectively pointer-only. This should be a plain Button with a localized accessibility label/value.

♿ Minimal fix
 if hasChildren {
-    Image(systemName: "chevron.right")
-        .font(.system(size: 9, weight: .bold))
-        .rotationEffect(.degrees(isCollapsed ? 0 : 90))
-        .foregroundColor(.secondary)
-        .frame(width: 12, height: 12)
-        .contentShape(Rectangle())
-        .onTapGesture {
-            tabManager.toggleWorkspaceCollapsed(tab.id)
-        }
+    Button {
+        tabManager.toggleWorkspaceCollapsed(tab.id)
+    } label: {
+        Image(systemName: "chevron.right")
+            .font(.system(size: 9, weight: .bold))
+            .rotationEffect(.degrees(isCollapsed ? 0 : 90))
+            .foregroundColor(.secondary)
+            .frame(width: 12, height: 12)
+            .contentShape(Rectangle())
+    }
+    .buttonStyle(.plain)
+    .accessibilityLabel(
+        String(
+            localized: isCollapsed ? "sidebar.workspace.expand" : "sidebar.workspace.collapse",
+            defaultValue: isCollapsed ? "Expand workspace" : "Collapse workspace"
+        )
+    )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 11092 - 11101, Replace the tappable
Image and onTapGesture with a proper Button that calls
tabManager.toggleWorkspaceCollapsed(tab.id) (use the existing
tabManager.toggleWorkspaceCollapsed symbol) so the control is keyboard and
VoiceOver accessible; keep the Image(systemName: "chevron.right") as the Button
label, preserve the rotationEffect/foregroundColor/frame, and add a localized
accessibilityLabel and accessibilityValue based on isCollapsed (e.g., "Collapse"
/ "Expand" or localized equivalents) so the state is announced to assistive
technologies.
Sources/TabManager.swift-2303-2315 (1)

2303-2315: ⚠️ Potential issue | 🟠 Major

Closing the last grouped workspace can strand the root.

After recursively closing children, the second guard tabs.count > 1 can fire before the requested parent is removed. A window containing only parent + descendants ends up keeping the parent open instead of closing the whole group/window.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 2303 - 2315, The second guard in
closeWorkspace(_:) can bail out before removing the requested parent, leaving
the root stranded; replace that guard with logic that checks whether the
workspace to remove still exists and only prevents removal when it is the very
last remaining tab. Specifically, in closeWorkspace(_:), after recursively
closing childWorkspaceIds, remove the second `guard tabs.count > 1 else { return
}` and instead check if `tabs.contains(where: { $0.id == workspace.id })` and if
`tabs.count == 1 && tabs.first?.id == workspace.id` then return; otherwise
proceed to remove the workspace (use the workspace.id and tabs collection to
locate and remove it).
Sources/TabManager.swift-5234-5248 (1)

5234-5248: ⚠️ Potential issue | 🟠 Major

Clear sidebar state before rebuilding it from a session snapshot.

The legacy/no-topLevelWorkspaceIndices branches only register onto the existing WorkspaceGroupManager.items array. Restoring into a live TabManager can therefore leave stale sidebar UUIDs from the pre-restore session.

🔧 Suggested fix
         // Rebuild sidebar layout from snapshot
+        groupManager.items.removeAll()
         if let topLevelIndices = snapshot.topLevelWorkspaceIndices {
             groupManager.items = topLevelIndices.compactMap { index in
                 guard newTabs.indices.contains(index) else { return nil }
                 return newTabs[index].id
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 5234 - 5248, Before rebuilding the
sidebar from the snapshot, clear the existing sidebar state so stale UUIDs
aren't preserved: inside the TabManager logic that handles snapshot restoration
(the block checking snapshot.topLevelWorkspaceIndices and
snapshot.sidebarLayout), reset the groupManager's stored items (e.g., clear or
assign an empty collection to groupManager.items) before you populate it via
topLevelWorkspaceIndices mapping, restoreLegacySidebarLayout(sidebarLayout,
workspaces: newTabs), or the loop that calls
groupManager.registerWorkspaceAsStandalone(ws.id); ensure items is then
re-assigned from groupManager.items after the rebuild.
Sources/TerminalControllerProjectCommands.swift-226-243 (1)

226-243: ⚠️ Potential issue | 🟠 Major

group.install_template currently truncates hierarchical templates.

This loop only materializes template.root.children. Any nested child.children are dropped, so installing the same template here produces a flatter tree than project.open_template.

🔧 Suggested fix
-            for child in template.root.children {
-                let ws = tm.addWorkspace(
-                    workingDirectory: workDir,
-                    select: false,
-                    skipStandaloneRegistration: true
-                )
-                ws.title = child.title
-                if let color = child.color {
-                    ws.customColor = color
-                }
-                tm.groupManager.addChildId(ws.id, to: parentId)
-                createdIds.append(ws.id.uuidString)
-
-                if let command = child.command,
-                   let panelId = ws.focusedPanelId ?? ws.panels.values.first(where: { $0 is TerminalPanel })?.id {
-                    scriptRunner.scheduleCommand(command, workspace: ws, panelId: panelId)
-                }
-            }
+            createChildWorkspaces(
+                children: template.root.children,
+                parentId: parentId,
+                workingDirectory: workDir,
+                tabManager: tm,
+                scriptRunner: scriptRunner,
+                createdIds: &createdIds
+            )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalControllerProjectCommands.swift` around lines 226 - 243, The
installation loop only materializes template.root.children and drops nested
child.children, producing a flattened tree; update the logic in the
group.install_template flow to recursively traverse the template node tree
(starting from template.root) and for each node create a workspace via
tm.addWorkspace, set ws.title/ws.customColor, call
tm.groupManager.addChildId(ws.id, to: parentId), append to createdIds, and
schedule any command with scriptRunner.scheduleCommand using the resolved
panelId; implement a helper function (e.g.,
materializeTemplateNode(node:parentId:)) that handles creating the workspace for
a node and then calls itself for each node.children so the original hierarchy is
preserved.
Sources/TerminalControllerProjectCommands.swift-127-130 (1)

127-130: ⚠️ Potential issue | 🟠 Major

Persist startupCommand before scheduling it.

TabManager.restoreSessionSnapshot(...) only replays saved workspace.startupCommand, but these socket/template paths schedule commands without ever assigning that field. Commands opened over the socket will run once and then disappear after a restore.

🔧 Suggested fix
             if let command = template.root.command,
                let panelId = rootWs.focusedPanelId ?? rootWs.panels.values.first(where: { $0 is TerminalPanel })?.id {
+                rootWs.startupCommand = command
                 scriptRunner.scheduleCommand(command, workspace: rootWs, panelId: panelId)
             }
...
             if let command = child.command,
                let panelId = ws.focusedPanelId ?? ws.panels.values.first(where: { $0 is TerminalPanel })?.id {
+                ws.startupCommand = command
                 scriptRunner.scheduleCommand(command, workspace: ws, panelId: panelId)
             }
...
                 if let command = child.command,
                    let panelId = ws.focusedPanelId ?? ws.panels.values.first(where: { $0 is TerminalPanel })?.id {
+                    ws.startupCommand = command
                     scriptRunner.scheduleCommand(command, workspace: ws, panelId: panelId)
                 }

Also applies to: 177-179, 239-241

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalControllerProjectCommands.swift` around lines 127 - 130, The
scheduled commands from template/socket are not persisted to the workspace
startupCommand, so TabManager.restoreSessionSnapshot(...) can’t replay them;
before calling scriptRunner.scheduleCommand(command, workspace: rootWs, panelId:
panelId) assign the command to the workspace's startupCommand (e.g.,
rootWs.startupCommand = command) so it is saved for restores; apply the same
change at the other occurrences mentioned (the blocks around the spots
corresponding to lines 177-179 and 239-241) so any command scheduled via
template/socket is persisted prior to scheduling.
Sources/TerminalControllerProjectCommands.swift-256-265 (1)

256-265: ⚠️ Potential issue | 🟠 Major

Project dedupe skips childless project roots.

The ws.hasChildren guard excludes the two creation paths above that intentionally produce a single root workspace: the plain-directory fallback and templates whose root has no children. Reopening those directories will duplicate them instead of focusing the existing workspace.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalControllerProjectCommands.swift` around lines 256 - 265, The
dedupe logic currently skips workspaces with no children because of the
ws.hasChildren guard, which causes single-root workspaces (plain-directory
fallback and certain templates) to be duplicated; update the loop in
TerminalControllerProjectCommands (where it iterates
appDelegate.allTabManagers() and uses tm.workspace(for: wsId)) to remove the
ws.hasChildren requirement — i.e., only guard for the existence of ws (guard let
ws = tm.workspace(for: wsId) else { continue }) and then compare
ws.currentDirectory (resolved to canonicalPath) as before, keeping the existing
actions (tm.selectedTabId = ws.id, tm.window?.makeKeyAndOrderFront(nil), return
.ok([...])) so single-root workspaces are matched and focused instead of
duplicated.
Sources/TerminalControllerProjectCommands.swift-51-63 (1)

51-63: ⚠️ Potential issue | 🟠 Major

Background project tabs need eager terminal startup.

These children are created off-screen with select: false, but unlike createChildWorkspaces(...) they never request background terminal startup. The scheduled script will not reach prompt-ready until the user manually opens the tab.

🔧 Suggested fix
                     for tabDef in result.tabDefinitions {
                         let childWs = tm.addWorkspace(
                             workingDirectory: dirURL.path,
                             select: false,
+                            eagerLoadTerminal: true,
                             skipStandaloneRegistration: true
                         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalControllerProjectCommands.swift` around lines 51 - 63, The
new off-screen child workspace created via tm.addWorkspace (childWs) never
requests background terminal startup, so scheduled scripts may not reach
prompt-ready; update the flow to request the same background terminal startup
used by createChildWorkspaces for childWs (call the equivalent background
startup API on childWs or through tm) after tm.groupManager.addChildId(...) and
before scriptRunner.scheduleScript(...), ensuring the terminal is started in the
background when select: false so the scriptRunner.scheduleScript will run to
prompt-ready.
Sources/TabManager.swift-1871-1880 (1)

1871-1880: ⚠️ Potential issue | 🟠 Major

Don't silently downgrade a failed template open to a plain workspace.

If the selected template cannot be loaded, try? swallows the error and this method falls through to creating a normal workspace. The caller asked for a hierarchy, so this should surface the failure and abort instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TabManager.swift` around lines 1871 - 1880, The code currently
swallows template-loading errors with `try?
TemplateRepository.shared.getTemplate(named:)` and falls back to creating a
plain workspace; instead, change this to a throwing call so failures are
surfaced: replace the `try?` with `try` (or otherwise handle the thrown error)
and make the enclosing function propagate the error (add `throws`) so that a
failed `TemplateRepository.shared.getTemplate(named:)` aborts rather than
falling through to `addWorkspace(workingDirectory:select:)`; ensure callers of
this method are updated to handle the thrown error and keep the
`openTemplate(_:directory:)` branch unchanged so the requested hierarchy is only
created on successful template load.
🟡 Minor comments (8)
Sources/NewWorkspaceDialog.swift-39-41 (1)

39-41: ⚠️ Potential issue | 🟡 Minor

Guard popup index before template array access.

Line 40 assumes a non-negative selected index. If indexOfSelectedItem is -1, this becomes an out-of-bounds access.

Suggested fix
         let selectedIndex = accessory.popup.indexOfSelectedItem
-        let templateName: String? = selectedIndex == 0 ? nil : templates[selectedIndex - 1]
+        let templateName: String?
+        if selectedIndex <= 0 {
+            templateName = nil
+        } else if (selectedIndex - 1) < templates.count {
+            templateName = templates[selectedIndex - 1]
+        } else {
+            templateName = nil
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/NewWorkspaceDialog.swift` around lines 39 - 41, The code assumes
accessory.popup.indexOfSelectedItem is non-negative and indexes templates with
selectedIndex - 1; guard against indexOfSelectedItem being -1 by checking the
selectedIndex value before accessing templates (e.g., treat -1 as no selection
and map to templateName = nil, or ensure selectedIndex >= 1 and selectedIndex -
1 < templates.count), updating the logic around selectedIndex/templateName in
NewWorkspaceDialog (the accessory.popup.indexOfSelectedItem usage) to avoid
out‑of‑bounds access.
Sources/ScriptManagerViewModel.swift-76-93 (1)

76-93: ⚠️ Potential issue | 🟡 Minor

Localize the "Copy" suffix for duplicated scripts.

The "Copy" and "Copy \(counter)" strings are user-facing (visible in the script manager sidebar) but are not wrapped in String(localized:defaultValue:).

🌐 Proposed fix for localization
     func duplicateSelected() {
         errorMessage = nil
         guard let name = selectedName,
               let content = repo.getScript(named: name) else { return }
-        var copyName = "\(name) Copy"
+        var copyName = String(
+            localized: "scriptManager.copyScriptName",
+            defaultValue: "\(name) Copy"
+        )
         var counter = 1
         while repo.hasScript(named: copyName) {
             counter += 1
-            copyName = "\(name) Copy \(counter)"
+            copyName = String(
+                localized: "scriptManager.copyScriptNameNumbered",
+                defaultValue: "\(name) Copy \(counter)"
+            )
         }

As per coding guidelines: "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") for every string shown in the UI."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ScriptManagerViewModel.swift` around lines 76 - 93, The
duplicated-script name construction in duplicateSelected() uses hardcoded
user-facing strings ("Copy" and "Copy \(counter)") — update it to use localized
strings: replace the literal "Copy" and the interpolated "Copy \(counter)" with
String(localized:..., defaultValue:...) (e.g. use a "script.copy" key for the
base suffix and format the numbered variant via String(format:localized: or
String(localized:..., defaultValue: "%@ %d") with the script name and counter).
Ensure copies created in duplicateSelected() (variables copyName and the
while-loop) use the localized suffix before calling repo.saveScript and when
calling selectScript/reload.
Sources/Workspace.swift-5088-5090 (1)

5088-5090: ⚠️ Potential issue | 🟡 Minor

Clean up stale onPromptReady callbacks when panels are removed.

Line 5090 introduces per-panel closures, but they’re only removed on Line 5862 when .promptIdle is reached. If a panel closes before that, callbacks can be retained longer than needed.

💡 Suggested cleanup points
// In pruneSurfaceMetadata(validSurfaceIds:)
+ onPromptReady = onPromptReady.filter { validSurfaceIds.contains($0.key) }

// In didCloseTab cleanup path (after resolving panelId)
+ onPromptReady.removeValue(forKey: panelId)

// In didClosePane cleanup loop
+ onPromptReady.removeValue(forKey: panelId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 5088 - 5090, The onPromptReady
dictionary stores per-panel one-shot closures but is only cleared when a panel
reaches .promptIdle; ensure you remove any stored closure when a panel is
removed or closed to avoid retaining stale callbacks—add cleanup of
onPromptReady[panelID] in the code paths that remove/close panels (e.g., in the
panel removal/close handler(s) such as removePanel/closePanel/panelDidClose or
any workspace panel-replacement logic) and also when a panel is replaced or its
UUID changes; reference the onPromptReady property to locate and delete the
entry for the panel's UUID in those code paths.
cmuxTests/WorkspaceGroupManagerTests.swift-213-233 (1)

213-233: ⚠️ Potential issue | 🟡 Minor

Incomplete test: missing assertion for depth constraint blocking.

This test sets up the scenario and comments about depth constraints, but never actually asserts that indentation is blocked when max depth would be exceeded. Lines 229-233 create ws4 at depth 4 but don't attempt an indent operation or verify it fails.

Proposed fix to complete the test
         // Now ws1 -> ws2 -> ws3 (ws3 is at depth 3)
         // Create ws4 and try to indent under ws3 — would exceed depth 3
         let ws4 = tabManager.addWorkspace()
-        ws3.childWorkspaceIds = [ws4.id]
-        // ws4 is now at depth 4 which is the state but indenting ws3 further isn't possible
-        // since ws3 is a child of ws2 which is a child of ws1
+        manager.registerWorkspaceAsStandalone(ws4.id)
+
+        // ws4 has a child, so indenting ws4 under ws3 would put the child at depth 4+
+        let ws5 = tabManager.addWorkspace()
+        ws4.childWorkspaceIds = [ws5.id]
+
+        // Attempt to indent ws4 under ws3 — should fail due to depth constraint
+        let blockedResult = manager.indentWorkspace(ws4.id)
+        XCTAssertFalse(blockedResult, "Indent should be blocked when it would exceed max depth")
+        // ws4 should remain a top-level item
+        XCTAssertTrue(manager.items.contains(ws4.id))
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cmuxTests/WorkspaceGroupManagerTests.swift` around lines 213 - 233, The test
testIndentBlockedByDepthConstraint is incomplete: after creating ws4 as a child
of ws3 you must attempt the indent and assert it is blocked. Update the test to
(1) ensure ws3 and ws4 are registered via
manager.registerWorkspaceAsStandalone(...) if needed, (2) call let result2 =
manager.indentWorkspace(ws3.id) (or indentWorkspace on the workspace that would
push ws4 beyond max depth), and (3) XCTAssertFalse(result2) (and optionally
assert the workspace hierarchy didn't change). Use the existing symbols
testIndentBlockedByDepthConstraint, ws3, ws4, and manager.indentWorkspace to
locate where to add this assertion.
Sources/TemplateManagerViewModel.swift-94-99 (1)

94-99: ⚠️ Potential issue | 🟡 Minor

Localize the generated duplicate-name suffix.

Lines 94-99 hard-code Copy, so duplicated templates stay English in every locale even though the “New Template” flow is localized. Please build both the base and numbered variants from localization keys as well.

As per coding guidelines, "All user-facing strings must be localized using String(localized: "key.name", defaultValue: "English text") for every string shown in the UI."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TemplateManagerViewModel.swift` around lines 94 - 99, The
duplicate-name generation currently hardcodes "Copy"; change it to use localized
strings by building the base suffix and the numbered variant via
String(localized:...): replace the literal "Copy" in the copyName construction
with a localized base suffix (e.g., a key like "template.copy.suffix") and
format the numbered variant using another localized format string (e.g.,
"template.copy.suffix.number" or an interpolated localized string) while keeping
the loop using repo.hasTemplate(named:) and the same copyName variable to ensure
uniqueness; ensure you call String(localized: "key", defaultValue: "English
text") for both the base and numbered forms in TemplateManagerViewModel where
copyName is computed.
Sources/WorkspaceGroupManager.swift-149-171 (1)

149-171: ⚠️ Potential issue | 🟡 Minor

Outdent logic is correct but could fail silently.

If neither the grandparent nor top-level insertion path is taken (e.g., due to stale data), the method returns true despite not actually inserting the workspace anywhere. Consider returning false if insertion didn't occur.

Proposed fix
     `@discardableResult`
     func outdentWorkspace(_ workspaceId: UUID) -> Bool {
         guard let tabManager else { return false }
         guard let parent = parentWorkspace(of: workspaceId) else { return false }

         // Remove from parent's children
         parent.childWorkspaceIds.removeAll { $0 == workspaceId }

         // Find where parent lives and insert after it
         if let grandparent = parentWorkspace(of: parent.id) {
             // Parent is a child itself — insert in grandparent's children after parent
             if let parentIdx = grandparent.childWorkspaceIds.firstIndex(of: parent.id) {
                 grandparent.childWorkspaceIds.insert(workspaceId, at: parentIdx + 1)
+                return true
             }
         } else if let parentTopIdx = items.firstIndex(of: parent.id) {
             // Parent is top-level — insert in items after parent
             items.insert(workspaceId, at: parentTopIdx + 1)
+            return true
         }

-        return true
+        // Failed to find insertion point — workspace is now orphaned
+        return false
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/WorkspaceGroupManager.swift` around lines 149 - 171, The
outdentWorkspace(_:) flow currently removes workspaceId from
parent.childWorkspaceIds then may fail to insert it (so returns true
incorrectly); modify it to record the parent's original child index, attempt
insertion into grandparent.childWorkspaceIds or items while tracking a Bool
inserted, and if insertion never happens, restore parent.childWorkspaceIds by
reinserting workspaceId at the recorded index and return false; update
references in this logic to parentWorkspace(of:), parent.childWorkspaceIds,
grandparent.childWorkspaceIds, items and ensure outdentWorkspace(_:) returns
true only when inserted is true.
Sources/TemplateRepository.swift-99-106 (1)

99-106: ⚠️ Potential issue | 🟡 Minor

seedDefaultTemplates bypasses NameSanitizer and silently swallows errors.

Unlike other methods, seedDefaultTemplates uses name directly (line 103) rather than calling NameSanitizer.sanitize(name). While the hardcoded names are safe, this inconsistency could become a problem if the method is later generalized. Additionally, both try? usages silently ignore failures, making it hard to diagnose seeding issues.

Proposed fix
     func seedDefaultTemplates() {
         for (name, content) in Self.defaultTemplates {
             guard !hasTemplate(named: name) else { continue }
-            try? ensureDirectoryExists()
-            let path = directory.appendingPathComponent("\(name).yaml")
-            try? content.write(to: path, atomically: true, encoding: .utf8)
+            do {
+                try ensureDirectoryExists()
+                let safeName = try NameSanitizer.sanitize(name)
+                let path = directory.appendingPathComponent("\(safeName).yaml")
+                try content.write(to: path, atomically: true, encoding: .utf8)
+            } catch {
+                // Log seeding failure for diagnostics
+                print("[TemplateRepository] Failed to seed template '\(name)': \(error)")
+            }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TemplateRepository.swift` around lines 99 - 106, seedDefaultTemplates
currently writes files using the raw template name and swallows failures with
try?, so update seedDefaultTemplates to sanitize the filename via
NameSanitizer.sanitize(name) before building the path, and replace the silent
try? calls around ensureDirectoryExists() and content.write(...) with proper
error handling (use do-catch and either log the error or rethrow so failures are
visible). Ensure you still check hasTemplate(sanitizedName) and build the path
with directory.appendingPathComponent("\(sanitizedName).yaml") so the behavior
matches other methods.
Sources/TerminalControllerScriptTemplateCommands.swift-61-68 (1)

61-68: ⚠️ Potential issue | 🟡 Minor

Error type conflation in v2TemplateGet.

Catching all errors and returning not_found masks distinct failure modes (parse errors, permission errors, disk I/O errors). Callers cannot distinguish between "template doesn't exist" and "template exists but is malformed."

Consider checking file existence separately or mapping different error types to distinct codes.

Proposed fix
     func v2TemplateGet(params: [String: Any]) -> V2CallResult {
         guard let name = params["name"] as? String else {
             return .err(code: "missing_param", message: "Missing 'name'", data: nil)
         }
+        guard TemplateRepository.shared.hasTemplate(named: name) else {
+            return .err(code: "not_found", message: "Template '\(name)' not found", data: nil)
+        }
         do {
             let template = try TemplateRepository.shared.getTemplate(named: name)
             let result = serializeTemplateNode(template.root)
             return .ok(["name": name, "root": result])
         } catch {
-            return .err(code: "not_found", message: "Template '\(name)' not found", data: nil)
+            return .err(code: "parse_error", message: error.localizedDescription, data: nil)
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TerminalControllerScriptTemplateCommands.swift` around lines 61 - 68,
The catch-all in v2TemplateGet masks distinct failure modes; update the error
handling around TemplateRepository.shared.getTemplate(named:) and
serializeTemplateNode(...) to distinguish "not found" from parse/IO/permission
errors by first checking existence (e.g., a
TemplateRepository.shared.templateExists(named:) or FileManager check) and then
mapping thrown errors to appropriate codes (e.g., "not_found" for missing,
"parse_error" for deserialization/serializeTemplateNode failures,
"io_error"/"permission_denied" for disk access errors). Specifically, change the
do/catch so that if templateExists returns false you return .err(code:
"not_found", ...), otherwise rethrow or inspect the thrown error and return
different .err codes/messages for parsing vs I/O/permission problems,
referencing getTemplate(named:) and serializeTemplateNode to locate the relevant
code paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 848c8c0b-53ea-411b-809d-97eefad9d387

📥 Commits

Reviewing files that changed from the base of the PR and between 99ca3c9 and d84739d.

📒 Files selected for processing (43)
  • CLI/cmux.swift
  • GhosttyTabs.xcodeproj/project.pbxproj
  • Resources/Info.plist
  • Sources/AppDelegate.swift
  • Sources/CmuxConfigError.swift
  • Sources/CmuxConfigParser.swift
  • Sources/CmuxConfigParserGroups.swift
  • Sources/ContentView.swift
  • Sources/GroupHeaderView.swift
  • Sources/GroupItem.swift
  • Sources/MonospaceTextEditor.swift
  • Sources/NameSanitizer.swift
  • Sources/NewWorkspaceDialog.swift
  • Sources/Panels/TerminalPanel.swift
  • Sources/ScriptManagerViewModel.swift
  • Sources/ScriptManagerWindow.swift
  • Sources/ScriptRepository.swift
  • Sources/ScriptsMenu.swift
  • Sources/SessionPersistence.swift
  • Sources/SessionWorkspaceGroupSnapshot.swift
  • Sources/StartupScriptRunner.swift
  • Sources/TabManager.swift
  • Sources/TemplateManagerHelpSidebar.swift
  • Sources/TemplateManagerViewModel.swift
  • Sources/TemplateManagerWindow.swift
  • Sources/TemplateRepository.swift
  • Sources/TemplateYamlParser.swift
  • Sources/TerminalController.swift
  • Sources/TerminalControllerGroupCommands.swift
  • Sources/TerminalControllerProjectCommands.swift
  • Sources/TerminalControllerScriptTemplateCommands.swift
  • Sources/Update/UpdateTitlebarAccessory.swift
  • Sources/Workspace.swift
  • Sources/WorkspaceGroup.swift
  • Sources/WorkspaceGroupManager.swift
  • Sources/cmuxApp.swift
  • cmuxTests/CmuxConfigParserTests.swift
  • cmuxTests/NameSanitizerTests.swift
  • cmuxTests/ScriptRepositoryTests.swift
  • cmuxTests/SessionGroupPersistenceTests.swift
  • cmuxTests/TemplateRepositoryTests.swift
  • cmuxTests/WorkspaceGroupManagerTests.swift
  • cmuxTests/WorkspaceGroupTests.swift

Comment on lines +40 to +43
func show() {
viewModel.reload()
window?.makeKeyAndOrderFront(nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

These code paths can drop a dirty script buffer without a Save/Discard/Cancel decision.

Line 41 reloads the singleton from disk every time the window is shown, and the Add / Duplicate / Delete actions on Lines 118-133 and 192-212 bypass the dirty-state prompt that selection changes already use. If viewModel.isDirty is true, these paths can overwrite the editor buffer, duplicate stale on-disk content, or delete the current script without first resolving the unsaved edits.

Also applies to: 118-133, 192-212

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ScriptManagerWindow.swift` around lines 40 - 43, The show() path and
the Add/Duplicate/Delete actions call viewModel.reload() or mutate scripts
without checking viewModel.isDirty, which can overwrite or lose unsaved editor
content; before calling viewModel.reload() in show() and before performing the
Add, Duplicate, and Delete handlers (the actions around the Add / Duplicate /
Delete blocks), detect viewModel.isDirty and present the same
Save/Discard/Cancel prompt used by selection-change logic, then act on the
user's choice (save then proceed, discard and proceed, or cancel the operation)
so the editor buffer is not dropped unintentionally.

Comment on lines +69 to +76
alert.beginSheetModal(for: window) { [weak self] response in
switch response {
case .alertFirstButtonReturn:
self?.viewModel.save()
onDiscard()
case .alertSecondButtonReturn:
self?.viewModel.revert()
onDiscard()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Only close or switch after a successful save.

The save branches here continue immediately after viewModel.save(), but save() communicates failure through errorMessage rather than control flow. When validation or disk I/O fails, the switch path still moves away from the current script, and the close path still retries the close flow instead of keeping the user on the editor.

Also applies to: 228-234

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ScriptManagerWindow.swift` around lines 69 - 76, The current alert
handlers call onDiscard() (or switch/close) immediately after viewModel.save(),
but save() reports failures via viewModel.errorMessage rather than a return
value; change both alert callback sites (the beginSheetModal handlers) to call
viewModel.save() then check viewModel.errorMessage (or a saveSuccess flag on the
viewModel) and only call onDiscard() / proceed with closing/switching if there
is no error; keep the revert path behavior unchanged but apply the same guard
after save in both places mentioned so the UI stays on the editor when save
fails.

Comment on lines +40 to +43
func show() {
viewModel.reload()
window?.makeKeyAndOrderFront(nil)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

These code paths can drop a dirty template buffer without a Save/Discard/Cancel decision.

Line 41 reloads the singleton from disk every time the window is shown, and the Add / Duplicate / Delete actions on Lines 121-136 and 195-215 bypass the dirty-state prompt that list selection already uses. If viewModel.isDirty is true, these paths can overwrite editorText, duplicate stale on-disk YAML, or delete the current template without ever asking what to do with the unsaved edits.

Also applies to: 121-136, 195-215

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TemplateManagerWindow.swift` around lines 40 - 43, The show() path
(calling viewModel.reload()) and the Add/Duplicate/Delete action handlers can
overwrite or remove unsaved edits because they don't respect viewModel.isDirty;
before calling viewModel.reload() in show() and before performing Add,
Duplicate, or Delete you must check viewModel.isDirty and present the same
Save/Discard/Cancel dialog used for list selection: on Save invoke the existing
save routine, on Discard proceed with reload/add/duplicate/delete, on Cancel
abort the operation; ensure editorText and any duplicate logic read from the
in-memory model (not stale on-disk) only after handling the dirty-state decision
so no unsaved edits are lost.

Comment on lines +69 to +76
alert.beginSheetModal(for: window) { [weak self] response in
switch response {
case .alertFirstButtonReturn:
self?.viewModel.save()
onDiscard()
case .alertSecondButtonReturn:
self?.viewModel.revert()
onDiscard()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Only close or switch after a successful save.

The save branches here continue immediately after viewModel.save(), but save() reports failure by mutating errorMessage instead of throwing. A validation or write error therefore still advances the workflow: the switch path discards the current buffer, and the close path just re-enters the close flow. Please make save() return success/failure and gate the follow-up action on that result.

Also applies to: 231-237

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/TemplateManagerWindow.swift` around lines 69 - 76, The save branch
currently calls viewModel.save() but proceeds to onDiscard()/close regardless of
whether save actually succeeded (save reports errors via errorMessage), so
change viewModel.save() to return a Bool or Result (e.g., save() -> Bool or
save() -> Result<Void, Error>), update the call sites in
TemplateManagerWindow.swift (the alert completion handler and the similar block
at lines 231-237) to check the returned success value before calling onDiscard()
or continuing the close flow, and only proceed when the save indicates success;
keep viewModel.revert() behavior unchanged for the discard path.

hirscr and others added 2 commits March 27, 2026 01:54
Replace defaultButtonCell/keyEquivalent overrides with hasDestructiveAction
in all three close-confirmation alerts. Adds Phase 1 shadow fields for
menu-state decoupling and TabItemDisplaySnapshot infrastructure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…bmenu flicker

Move sidebar context menu from SwiftUI .contextMenu to an AppKit NSMenu
built at right-click time via NSMenuDelegate.menuNeedsUpdate. The menu is
fully static while open, decoupled from the SwiftUI rendering pipeline.
Removes ~300 lines of dead SwiftUI menu code from ContentView.swift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@hirscr

hirscr commented Mar 27, 2026

Copy link
Copy Markdown
Author

Closing — this fork has diverged significantly and is maintained independently.

@hirscr hirscr closed this Mar 27, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 12 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/SidebarContextMenuController.swift">

<violation number="1" location="Sources/SidebarContextMenuController.swift:164">
P2: "Clear Color" menu visibility checks only the clicked workspace, hiding valid bulk-clear for selected workspaces.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

let parentItem = NSMenuItem(title: String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color"), action: nil, keyEquivalent: "")
parentItem.submenu = colorMenu

if workspace.customColor != nil {

@cubic-dev-ai cubic-dev-ai Bot Mar 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: "Clear Color" menu visibility checks only the clicked workspace, hiding valid bulk-clear for selected workspaces.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/SidebarContextMenuController.swift, line 164:

<comment>"Clear Color" menu visibility checks only the clicked workspace, hiding valid bulk-clear for selected workspaces.</comment>

<file context>
@@ -0,0 +1,421 @@
+        let parentItem = NSMenuItem(title: String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color"), action: nil, keyEquivalent: "")
+        parentItem.submenu = colorMenu
+
+        if workspace.customColor != nil {
+            colorMenu.addItem(ActionMenuItem(
+                String(localized: "contextMenu.clearColor", defaultValue: "Clear Color")
</file context>
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant