Skip to content

feat(workspaces): Group repositories and scope the board per workspace#374

Draft
eyelock wants to merge 1 commit into
developfrom
feat/workspaces
Draft

feat(workspaces): Group repositories and scope the board per workspace#374
eyelock wants to merge 1 commit into
developfrom
feat/workspaces

Conversation

@eyelock

@eyelock eyelock commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Summary

Adds Workspaces — named groupings that let a repo-heavy machine be narrowed to one context at a time. A workspace is a filter, mirroring the existing repos.json pattern; it is not a separate store or board file.

  • Repositories: the sidebar repo list filters to the active workspace. "All Repositories" shows every repo.
  • Board: one board.json; each card carries a workspaceId. "All" shows every card; a workspace shows only its cards. Switching workspace is a pure display re-filter — sessions are never torn down.
  • Agents: pinned via TERMQ_WORKSPACE_ID (injected at terminal creation) — the MCP server and termq-cli stamp new cards with it and filter their reads to it.

Status — DRAFT

Feature-complete and verified (make check green: build + lint + format + 2083 tests, 0 failures, 0 warnings; the one lint note is ContentView type_body_length, inherited from develop, not this branch). Pending live in-app validation. Not for merge yet.

Design decisions (and the history a new contributor needs)

This supersedes an earlier separate-board-file-per-workspace approach (board-<id>.json + a file swap on switch). Why it was abandoned: "All" must remain a fully-functional working board (it is the default board, and the only board before any workspace exists). A per-file model can only aggregate all workspaces into "All" via a read-only merge — unacceptable. The single-board + per-card-tag + filter model dissolves that and reuses the repo-filter pattern already in the codebase.

Locked rules:

  • activeWorkspaceId == nil ("All") → every card / every repo. A workspace → only items tagged with it. Unassigned items (no workspaceId) appear only in "All" — same rule as zero-membership repos.
  • A card's workspace is fixed at creation (the then-active workspace, or nil in "All").
  • Columns are global (shared across workspaces) — see Deferred.

Architecture / file map

  • Workspace model & store: Sources/TermQ/Models/Workspace.swift, WorkspaceFilter.swift; Sources/TermQ/Services/WorkspaceStore.swift (workspaces.json = definitions + active selection).
  • Repo sidebar: Views/Sidebar/WorkspaceSwitcher.swift, ManageWorkspacesSheet.swift, WorktreeSidebarView(+Workspace).swift; ViewModels/WorktreeSidebarViewModel.swift (displayedRepositories). Strings in Utilities/Strings+Sidebar.swift localized to all 40 locales.
  • Per-card tag: Sources/TermQShared/Card.swift + Sources/TermQCore/TerminalCard.swiftworkspaceId: UUID? (additive optional, decodeIfPresent).
  • Filter helpers: Board.cardsInWorkspace(_:workspaceId:) in TermQShared/Board.swift (MCP/CLI path); BoardViewModel.cardsInWorkspace(_:active:) (app path).
  • App display: BoardViewModel.displayedCards(for:) filters by activeWorkspaceProvider() — an injectable seam (defaults to WorkspaceStore.shared; tests inject a fixed value, no singleton mutation). KanbanBoardView renders it.
  • App stamping: new cards stamped with newCardWorkspaceId in addTerminal/newTerminal/quickNewTerminal, duplicateTerminal (inherits source), and HarnessLaunchCoordinator.
  • Switch = re-filter: BoardViewModel.observeWorkspaceSwitch() emits objectWillChange on WorkspaceStore.$activeWorkspaceId (no file swap, no session teardown).
  • Env injection: ViewModels/TerminalSessionManager.swift injects TERMQ_WORKSPACE_ID at terminal creation.
  • Agent pinning: MCPServerLib/Server.swift holds workspaceId (from MCPServer-CLI/main.swift reading the env); ToolHandlers filter handleList/handlePending/handleFind; HeadlessWriter/BoardWriter.createCard(workspaceId:) stamp. CLI: TermQCLICore/CLI.swift resolveWorkspaceId() + filter in CLI+Find/CLI+LLM/CLI list, stamp in CLI New/Create. BoardWriter edits raw JSON dicts and preserves unknown keys, so only the create path needed touching for writes.

Tests

  • TermQSharedTests/WorkspaceCardFilterTests — pure filter edge cases, Card round-trip, BoardWriter.createCard stamping.
  • MCPServerLibTests/ToolHandlersWorkspaceFilterTests — server pinned vs unpinned actually filters handleList/handlePending.
  • MCPServerLibTests/HeadlessWriterWorkspaceTests — create-stamp + persistence.
  • TermQTests/BoardViewModelWorkspaceFilterTestsdisplayedCards honours injected workspace; addTerminal stamps.
  • TermQTests/TerminalCardWorkspaceTestsTerminalCard round-trip (present + legacy-absent → nil).

Pick-up points for the next agent

  1. Live in-app validation pending — launch the debug build and confirm: "All" aggregates every card; each workspace filters correctly; switching preserves Direct-mode and tmux sessions (Direct shells have no server to detach to); an agent launched in a workspace creates cards that land in that workspace.
  2. Deferred by design ("make it feature-rich later"):
    • Per-workspace columns — would turn "All" into a grouped/swimlane view; columns are global for now.
    • Card reassignment ("move card to another workspace") — workspace is fixed at creation.
    • Tab bar is not workspace-filtered — only the kanban board is.
  3. Known coverage gap: CLI command-level filtering isn't harnessed (the shared filter + stamp it relies on are unit-tested). Adding termq find/list invocation tests would close it.
  4. No migration: workspaceId is additive-optional, so existing board.json cards decode as unassigned (visible in "All"). The obsolete board-<id>.json files from the earlier approach are never read/written by this code and were discarded from the author's debug data dir.

Verify

make check    # build + lint + format + test → green (2083 tests)

🤖 Generated with Claude Code

Workspaces let a repo-heavy machine be narrowed to one context at a time. A
workspace is a filter, mirroring the existing repos.json pattern — not a separate
store or board file.

Repositories
- Workspace model + WorkspaceStore (workspaces.json holds the definitions and the
  active selection in one file). Pure WorkspaceFilter for the visible-repo logic.
- Sidebar filters the repo list to the active workspace; switcher, Manage sheet,
  per-repo "Add to Workspace" menu, and a dedicated empty state.
- "All Repositories" = every repo (zero-membership repos appear only here).
- New user-facing strings localized across all 40 locales.

Board (single board.json, per-card workspace tag)
- Each card carries an optional workspaceId (TerminalCard + the Sendable Card DTO);
  additive and back-compatible — pre-existing cards decode as unassigned.
- One board.json. "All" shows every card; a workspace shows only its cards
  (unassigned cards show only in "All"). Switching workspace is a pure display
  re-filter — terminal sessions are never torn down.
- Agents are pinned via TERMQ_WORKSPACE_ID (injected at terminal creation): the MCP
  server and termq-cli stamp new cards with it and filter their reads to it, all
  against the one board.json.

Testing
- Pure filter (Board.cardsInWorkspace), card round-trips, create-stamping, the MCP
  handler-level filter, and the app's displayedCards/stamp via an injected
  active-workspace seam (no WorkspaceStore singleton coupling in tests).

Supersedes an earlier separate-board-file-per-workspace approach: "All" must remain
a fully-functional working board, which a per-file model can only do via a
read-only merge. The single-board filter dissolves that and mirrors the repo list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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