This document outlines the implementation plan for Worktree Connections, covering database schema, filesystem operations, IPC handlers, renderer stores, sidebar UI, session integration, and the connect dialog.
The implementation is divided into 10 focused sessions, each with:
- Clear objectives
- Definition of done
- Testing criteria for verification
Refer to: docs/prd/PRD_WORKTREE_CONNECTION.md for full product requirements.
Session 1 (DB Schema & Migration) -- no deps
Session 2 (Connection Service: Filesystem) -- no deps
Session 3 (Connection IPC Handlers) -- blocked by Sessions 1, 2
Session 4 (Preload Bridge & Types) -- blocked by Session 3
Session 5 (Connection Store) -- blocked by Session 4
Session 6 (Session Store: Connection Support) -- blocked by Sessions 1, 4
Session 7 (Connect Dialog UI) -- blocked by Session 5
Session 8 (Sidebar: ConnectionList & ConnectionItem) -- blocked by Sessions 5, 6
Session 9 (SessionView & MainPane Integration) -- blocked by Sessions 6, 8
Session 10 (Worktree Archive Cascade & Verification) -- blocked by Sessions 1-9
┌──────────────────────────────────────────────────────────────────────────────┐
│ Time → │
│ │
│ Track A: [S1: DB Schema] ──────────────────────┐ │
│ Track B: [S2: Filesystem Service] ─────────────┤ │
│ ▼ │
│ [S3: IPC Handlers] │
│ │ │
│ [S4: Preload & Types] │
│ ┌─────┴─────┐ │
│ ▼ ▼ │
│ [S5: Connection [S6: Session │
│ Store] Store] │
│ │ ╲ ╱ │ │
│ ▼ ╲ ╱ ▼ │
│ [S7: Connect [S8: Sidebar] │
│ Dialog] │ │
│ ▼ │
│ [S9: SessionView & │
│ MainPane] │
│ │ │
│ [S10: Archive Cascade │
│ & Verification] │
└──────────────────────────────────────────────────────────────────────────────┘
Maximum parallelism: Sessions 1 and 2 are fully independent.
Minimum total: 8 rounds (mostly sequential due to layered dependencies).
Recommended serial order: S1 → S2 → S3 → S4 → S5 → S6 → S7 → S8 → S9 → S10
Rationale: Each layer depends on the one below it. S1 and S2 can run in parallel. Everything else chains through IPC → preload → store → UI.
test/
├── worktree-connection/
│ ├── session-1/
│ │ └── connection-schema.test.ts
│ ├── session-2/
│ │ └── connection-service.test.ts
│ ├── session-3/
│ │ └── connection-handlers.test.ts
│ ├── session-4/
│ │ └── connection-preload.test.ts
│ ├── session-5/
│ │ └── connection-store.test.ts
│ ├── session-6/
│ │ └── session-store-connections.test.ts
│ ├── session-7/
│ │ └── connect-dialog.test.tsx
│ ├── session-8/
│ │ └── sidebar-connections.test.tsx
│ ├── session-9/
│ │ └── session-view-connections.test.tsx
│ └── session-10/
│ └── archive-cascade-integration.test.ts
# No new dependencies -- all features use existing packages:
# - better-sqlite3 (database -- already installed)
# - zustand (stores -- already installed)
# - lucide-react (icons -- already installed)
# - sonner (toasts -- already installed)
# - Node.js fs (symlinks -- built-in)- Add
connectionsandconnection_memberstables - Add
connection_idcolumn to thesessionstable - Make
project_idnullable onsessionsfor connection-scoped sessions - Bump
CURRENT_SCHEMA_VERSIONto 2
In src/main/db/schema.ts, bump CURRENT_SCHEMA_VERSION to 2 and append a new migration to the MIGRATIONS array:
{
version: 2,
up: (db) => {
db.exec(`
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS connection_members (
id TEXT PRIMARY KEY,
connection_id TEXT NOT NULL,
worktree_id TEXT NOT NULL,
project_id TEXT NOT NULL,
symlink_name TEXT NOT NULL,
added_at TEXT NOT NULL,
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE,
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE INDEX idx_connection_members_connection ON connection_members(connection_id);
CREATE INDEX idx_connection_members_worktree ON connection_members(worktree_id);
`)
// Add connection_id to sessions, make project_id nullable for connection sessions
db.exec(`
ALTER TABLE sessions ADD COLUMN connection_id TEXT DEFAULT NULL
REFERENCES connections(id) ON DELETE SET NULL;
CREATE INDEX idx_sessions_connection ON sessions(connection_id);
`)
}
}Note: SQLite does not support ALTER COLUMN to make project_id nullable. Since the column was created as NOT NULL, connection sessions should use a sentinel project_id from the first member. This avoids a table rebuild. See Open Question 1 in the PRD -- resolve by picking the first member's project_id as the session's project_id.
In src/main/db/database.ts, add methods for the new tables:
createConnection(data: ConnectionCreate): ConnectiongetConnection(id: string): ConnectionWithMembers | nullgetAllConnections(): ConnectionWithMembers[]updateConnection(id: string, data: Partial<Connection>): voiddeleteConnection(id: string): voidcreateConnectionMember(data: ConnectionMemberCreate): ConnectionMemberdeleteConnectionMember(connectionId: string, worktreeId: string): voidgetConnectionMembersByWorktree(worktreeId: string): ConnectionMember[]getActiveSessionsByConnection(connectionId: string): Session[]
The getConnection and getAllConnections methods should JOIN with connection_members, worktrees, and projects to return ConnectionWithMembers with enriched member data.
In src/main/db/types.ts, add:
interface ConnectionCreate {
name: string
path: string
}
interface ConnectionMemberCreate {
connection_id: string
worktree_id: string
project_id: string
symlink_name: string
}src/main/db/schema.ts-- migration v2src/main/db/database.ts-- CRUD methodssrc/main/db/types.ts-- create/update types
-
CURRENT_SCHEMA_VERSIONis2 -
connectionstable is created with id, name, path, status, timestamps -
connection_memberstable is created with FKs to connections, worktrees, projects -
sessionstable has aconnection_idcolumn (nullable, FK to connections) - Indexes exist on
connection_members.connection_id,connection_members.worktree_id,sessions.connection_id - All CRUD methods work: create, get, getAll, update, delete for connections and members
-
getConnectionreturns enriched members with worktree name, branch, path, project name - Deleting a connection cascades to
connection_members - Deleting a worktree cascades to its
connection_membersrows -
pnpm lintpasses -
pnpm testpasses
// test/worktree-connection/session-1/connection-schema.test.ts
describe('Session 1: Connection Schema', () => {
test('connections table is created by migration', () => {
// Run migration against in-memory SQLite
// Verify table exists with correct columns
})
test('connection_members table has correct foreign keys', () => {
// Insert a connection, then a member
// Verify FK constraint: inserting member with invalid connection_id fails
})
test('deleting a connection cascades to members', () => {
// Create connection + 2 members
// Delete connection
// Verify members are gone
})
test('deleting a worktree cascades to its connection_members', () => {
// Create connection + member referencing worktree
// Delete worktree
// Verify member row is deleted, connection still exists
})
test('sessions.connection_id column exists and is nullable', () => {
// Create a session with connection_id = null (existing behavior)
// Create a session with connection_id set
// Verify both work
})
test('getAllConnections returns enriched member data', () => {
// Create connection with 2 members from different projects
// Call getAllConnections
// Verify each member has worktree_name, worktree_branch, project_name
})
})- Create
connection-service.tswith all filesystem operations - Implement directory creation, symlink management, and AGENTS.md generation
- Handle edge cases: parent dir creation, symlink name collisions, broken symlinks
Create src/main/services/connection-service.ts with the following functions:
import fs from 'fs'
import path from 'path'
import { app } from 'electron'
const CONNECTIONS_BASE_DIR = path.join(app.getPath('home'), '.hive', 'connections')
export function getConnectionsBaseDir(): string {
return CONNECTIONS_BASE_DIR
}
export function ensureConnectionsDir(): void {
fs.mkdirSync(CONNECTIONS_BASE_DIR, { recursive: true })
}
export function createConnectionDir(name: string): string {
ensureConnectionsDir()
const dirPath = path.join(CONNECTIONS_BASE_DIR, name)
fs.mkdirSync(dirPath, { recursive: true })
return dirPath
}
export function deleteConnectionDir(connectionPath: string): void {
if (fs.existsSync(connectionPath)) {
fs.rmSync(connectionPath, { recursive: true, force: true })
}
}
export function createSymlink(targetPath: string, symlinkPath: string): void {
fs.symlinkSync(targetPath, symlinkPath, 'dir')
}
export function removeSymlink(symlinkPath: string): void {
if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
fs.unlinkSync(symlinkPath)
}
}
export function renameConnectionDir(oldPath: string, newPath: string): void {
fs.renameSync(oldPath, newPath)
}interface AgentsMdMember {
symlinkName: string
projectName: string
branchName: string
worktreePath: string
}
export function generateAgentsMd(connectionPath: string, members: AgentsMdMember[]): void {
const sections = members.map(
(m) => `### ${m.symlinkName}/
- **Project:** ${m.projectName}
- **Branch:** ${m.branchName}
- **Path:** ${m.worktreePath}`
)
const content = `# Connected Worktrees
This workspace contains symlinked worktrees from multiple projects.
Each subdirectory is a separate git repository.
## Projects
${sections.join('\n\n')}
## Working in this workspace
- Each subdirectory is a fully independent git repo
- Make commits in each subdirectory separately
- Changes in one project do not affect the other
`
fs.writeFileSync(path.join(connectionPath, 'AGENTS.md'), content, 'utf-8')
}export function deriveSymlinkName(projectName: string, existingNames: string[]): string {
const base = projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-')
if (!existingNames.includes(base)) return base
let counter = 2
while (existingNames.includes(`${base}-${counter}`)) counter++
return `${base}-${counter}`
}src/main/services/connection-service.ts-- new file
-
createConnectionDircreates~/.hive/connections/{name}/recursively -
deleteConnectionDirremoves the entire connection folder -
createSymlinkcreates a directory symlink from target to link path -
removeSymlinksafely removes a symlink (handles broken symlinks) -
generateAgentsMdwrites a valid AGENTS.md with all member info -
deriveSymlinkNamehandles collisions by appending numeric suffixes -
renameConnectionDirrenames the folder on disk -
ensureConnectionsDircreates parent dir if it does not exist -
pnpm lintpasses -
pnpm testpasses
// test/worktree-connection/session-2/connection-service.test.ts
describe('Session 2: Connection Service', () => {
test('deriveSymlinkName returns lowercase hyphenated project name', () => {
expect(deriveSymlinkName('My Frontend', [])).toBe('my-frontend')
})
test('deriveSymlinkName appends suffix on collision', () => {
expect(deriveSymlinkName('backend', ['backend'])).toBe('backend-2')
expect(deriveSymlinkName('backend', ['backend', 'backend-2'])).toBe('backend-3')
})
test('generateAgentsMd writes valid markdown with member sections', () => {
// Call generateAgentsMd with 2 members
// Read the file
// Verify it contains both project sections
// Verify the header and working instructions
})
test('createSymlink creates a working directory symlink', () => {
// Create a temp dir as target
// Create symlink
// Verify fs.lstatSync(symlink).isSymbolicLink()
// Verify reading through symlink works
})
test('removeSymlink handles broken symlinks', () => {
// Create symlink to non-existent target
// Call removeSymlink
// Verify no error, symlink removed
})
test('createConnectionDir creates nested directories', () => {
// Call with a name
// Verify directory exists
})
})- Create
connection-handlers.tswith all IPC handlers for connection operations - Wire up the connection service and database methods
- Register handlers in
src/main/index.ts
Create src/main/ipc/connection-handlers.ts:
import { ipcMain } from 'electron'
import { v4 as uuidv4 } from 'uuid'
import { getDatabase } from '../db'
import {
createConnectionDir,
createSymlink,
removeSymlink,
deleteConnectionDir,
generateAgentsMd,
deriveSymlinkName,
renameConnectionDir,
getConnectionsBaseDir
} from '../services/connection-service'
import { selectUniqueBreedName } from '../services/breed-names'
import path from 'path'Implement these handlers:
connection:create-- Takes{ worktreeIds: string[] }. Generates breed name, creates dir, creates symlinks for each worktree, generates AGENTS.md, inserts DB rows. ReturnsConnectionWithMembers.connection:delete-- Takes{ connectionId: string }. Deletes the filesystem directory, then the DB row (cascade handles members).connection:addMember-- Takes{ connectionId: string, worktreeId: string }. Looks up worktree and project, derives symlink name, creates symlink, inserts member row, regenerates AGENTS.md.connection:removeMember-- Takes{ connectionId: string, worktreeId: string }. Removes symlink, deletes member row, regenerates AGENTS.md. If no members remain, deletes the entire connection.connection:rename-- Takes{ connectionId: string, name: string }. Renames the folder on disk, updates DB.connection:getAll-- Returns all active connections with enriched members.connection:get-- Returns one connection with enriched members.connection:openInTerminal-- Reuse the existing terminal-opening logic from worktree handlers.connection:openInEditor-- Reuse the existing editor-opening logic.connection:removeWorktreeFromAll-- Takes{ worktreeId: string }. Finds all connections containing this worktree and removes it from each. Used by the archive cascade.
Import and call the registration function from connection-handlers.ts where other handlers are registered.
src/main/ipc/connection-handlers.ts-- new filesrc/main/index.ts-- register handlers
-
connection:creategenerates breed name, creates dir + symlinks + AGENTS.md + DB rows -
connection:deleteremoves dir and DB row -
connection:addMembercreates symlink, inserts member, regenerates AGENTS.md -
connection:removeMemberremoves symlink, deletes member, regenerates AGENTS.md -
connection:removeMemberdeletes the entire connection when last member is removed -
connection:renamerenames folder on disk and updates DB -
connection:getAllreturns enriched connections with member details -
connection:openInTerminalandconnection:openInEditorwork -
connection:removeWorktreeFromAllcleans up all connections for a given worktree - All handlers wrap errors in try/catch and return
{ success: false, error }on failure - Handlers are registered in main process startup
-
pnpm lintpasses -
pnpm testpasses
// test/worktree-connection/session-3/connection-handlers.test.ts
describe('Session 3: Connection IPC Handlers', () => {
test('connection:create creates dir, symlinks, AGENTS.md, and DB rows', () => {
// Mock database and filesystem
// Call handler with 2 worktree IDs
// Verify createConnectionDir called
// Verify createSymlink called twice
// Verify generateAgentsMd called
// Verify DB insert for connection + 2 members
})
test('connection:delete removes directory and DB row', () => {
// Mock existing connection
// Call handler
// Verify deleteConnectionDir called
// Verify DB delete called
})
test('connection:addMember creates symlink and regenerates AGENTS.md', () => {
// Mock existing connection with 1 member
// Call addMember with new worktree
// Verify symlink created
// Verify member row inserted
// Verify AGENTS.md regenerated with 2 members
})
test('connection:removeMember deletes connection when last member removed', () => {
// Mock connection with 1 member
// Call removeMember
// Verify entire connection is deleted (dir + DB)
})
test('connection:removeWorktreeFromAll cleans up across connections', () => {
// Mock worktree in 2 different connections
// Call removeWorktreeFromAll
// Verify member removed from both connections
})
})- Add
Connection,ConnectionMember,ConnectionWithMemberstypes toindex.d.ts - Add
connection_idto theSessiontype - Expose
window.connectionOpsnamespace in the preload bridge - Add connection-related methods to
window.db.session
interface Connection {
id: string
name: string
status: 'active' | 'archived'
path: string
created_at: string
updated_at: string
}
interface ConnectionMember {
id: string
connection_id: string
worktree_id: string
project_id: string
symlink_name: string
added_at: string
}
interface ConnectionWithMembers extends Connection {
members: (ConnectionMember & {
worktree_name: string
worktree_branch: string
worktree_path: string
project_name: string
})[]
}Add connection_id: string | null to the existing Session interface.
connectionOps: {
create: (worktreeIds: string[]) => Promise<ConnectionWithMembers>
delete: (connectionId: string) => Promise<{ success: boolean; error?: string }>
addMember: (connectionId: string, worktreeId: string) => Promise<ConnectionMember>
removeMember: (connectionId: string, worktreeId: string) => Promise<{ success: boolean }>
rename: (connectionId: string, name: string) => Promise<{ success: boolean }>
getAll: () => Promise<ConnectionWithMembers[]>
get: (connectionId: string) => Promise<ConnectionWithMembers>
openInTerminal: (connectionPath: string) => Promise<void>
openInEditor: (connectionPath: string) => Promise<void>
removeWorktreeFromAll: (worktreeId: string) => Promise<void>
}getByConnection: (connectionId: string) => Promise<Session[]>
getActiveByConnection: (connectionId: string) => Promise<Session[]>Add connectionOps context bridge:
connectionOps: {
create: (worktreeIds: string[]) =>
ipcRenderer.invoke('connection:create', { worktreeIds }),
delete: (connectionId: string) =>
ipcRenderer.invoke('connection:delete', { connectionId }),
addMember: (connectionId: string, worktreeId: string) =>
ipcRenderer.invoke('connection:addMember', { connectionId, worktreeId }),
removeMember: (connectionId: string, worktreeId: string) =>
ipcRenderer.invoke('connection:removeMember', { connectionId, worktreeId }),
rename: (connectionId: string, name: string) =>
ipcRenderer.invoke('connection:rename', { connectionId, name }),
getAll: () => ipcRenderer.invoke('connection:getAll'),
get: (connectionId: string) =>
ipcRenderer.invoke('connection:get', { connectionId }),
openInTerminal: (connectionPath: string) =>
ipcRenderer.invoke('connection:openInTerminal', { connectionPath }),
openInEditor: (connectionPath: string) =>
ipcRenderer.invoke('connection:openInEditor', { connectionPath }),
removeWorktreeFromAll: (worktreeId: string) =>
ipcRenderer.invoke('connection:removeWorktreeFromAll', { worktreeId })
}Add session connection DB handlers in the existing db.session section.
src/preload/index.d.ts-- types and window interfacesrc/preload/index.ts-- context bridge wiring
-
Connection,ConnectionMember,ConnectionWithMemberstypes are declared -
Sessiontype includesconnection_id: string | null -
window.connectionOpsis fully typed and wired to IPC channels -
window.db.session.getByConnectionandgetActiveByConnectionare exposed - TypeScript compilation passes with no errors in preload files
-
pnpm lintpasses -
pnpm testpasses
// test/worktree-connection/session-4/connection-preload.test.ts
describe('Session 4: Preload Bridge & Types', () => {
test('connectionOps methods are exposed on window', () => {
// Verify window.connectionOps.create is a function
// Verify window.connectionOps.delete is a function
// Verify window.connectionOps.getAll is a function
// etc.
})
test('Session type accepts connection_id', () => {
// TypeScript compilation check
const session: Session = {
// ... required fields
connection_id: 'conn-1',
worktree_id: null
}
expect(session.connection_id).toBe('conn-1')
})
test('Session type accepts null connection_id', () => {
const session: Session = {
// ... required fields
connection_id: null,
worktree_id: 'wt-1'
}
expect(session.connection_id).toBeNull()
})
})- Create
useConnectionStorewith state, actions, and persistence - Handle loading, creating, deleting, adding/removing members, renaming, selecting
- Integrate selection deconfliction with
useWorktreeStore
Create src/renderer/src/stores/useConnectionStore.ts:
interface ConnectionState {
connections: ConnectionWithMembers[]
isLoading: boolean
error: string | null
selectedConnectionId: string | null
// Actions
loadConnections: () => Promise<void>
createConnection: (worktreeIds: string[]) => Promise<string | null>
deleteConnection: (connectionId: string) => Promise<void>
addMember: (connectionId: string, worktreeId: string) => Promise<void>
removeMember: (connectionId: string, worktreeId: string) => Promise<void>
renameConnection: (connectionId: string, name: string) => Promise<void>
selectConnection: (id: string | null) => void
}Key implementation details:
loadConnectionscallswindow.connectionOps.getAll()and sets statecreateConnectioncallswindow.connectionOps.create(), adds to local state, selects the new connectiondeleteConnectioncallswindow.connectionOps.delete(), removes from state, clears selection if neededselectConnectionsetsselectedConnectionIdAND clearsuseWorktreeStore.selectedWorktreeIdto null (deconfliction)- Use
persistmiddleware to rememberselectedConnectionIdin localStorage
In useWorktreeStore.selectWorktree, add:
// Clear any selected connection when a worktree is selected
useConnectionStore.getState().selectConnection(null)This is a cross-store call. Import useConnectionStore lazily to avoid circular deps if needed.
Add useConnectionStore to src/renderer/src/stores/index.ts.
src/renderer/src/stores/useConnectionStore.ts-- new filesrc/renderer/src/stores/useWorktreeStore.ts-- deconfliction inselectWorktreesrc/renderer/src/stores/index.ts-- barrel export
-
useConnectionStoreloads, creates, deletes, renames connections via IPC -
addMemberandremoveMemberupdate local state and call IPC -
selectConnectiondeselects any active worktree -
selectWorktreedeselects any active connection -
selectedConnectionIdis persisted to localStorage - The store handles errors gracefully (try/catch, toast on failure)
-
pnpm lintpasses -
pnpm testpasses
// test/worktree-connection/session-5/connection-store.test.ts
describe('Session 5: Connection Store', () => {
test('loadConnections fetches from IPC and updates state', () => {
// Mock window.connectionOps.getAll returning 2 connections
// Call loadConnections
// Verify state.connections has 2 entries
})
test('createConnection adds to state and selects it', () => {
// Mock window.connectionOps.create
// Call createConnection
// Verify new connection in state
// Verify selectedConnectionId is set
})
test('selectConnection clears selectedWorktreeId', () => {
// Set selectedWorktreeId to 'wt-1'
// Call selectConnection('conn-1')
// Verify selectedWorktreeId is null
// Verify selectedConnectionId is 'conn-1'
})
test('selectWorktree clears selectedConnectionId', () => {
// Set selectedConnectionId to 'conn-1'
// Call selectWorktree('wt-1')
// Verify selectedConnectionId is null
})
test('deleteConnection removes from state and clears selection', () => {
// Add connection, select it
// Call deleteConnection
// Verify removed from state
// Verify selectedConnectionId is null
})
})- Add connection-scoped session maps to
useSessionStore - Add methods to load, create, close sessions for connections
- Add DB handlers for
getByConnectionandgetActiveByConnection
// New state fields
sessionsByConnection: Map<string, Session[]>
tabOrderByConnection: Map<string, string[]>
activeSessionByConnection: Record<string, string> // persistedloadConnectionSessions(connectionId: string)-- callswindow.db.session.getActiveByConnection(connectionId), populatessessionsByConnectionandtabOrderByConnectioncreateConnectionSession(connectionId: string)-- creates a session withconnection_idset andworktree_idnull. Determinesproject_idfrom the first member of the connection (query viawindow.connectionOps.get()). Adds to tab order, sets as active.setActiveConnectionSession(sessionId: string)-- sets active for connection context. Persists toactiveSessionByConnection.setActiveConnection(connectionId: string)-- setsactiveConnectionIdin session store, restores last active session for that connection.
Reuse existing closeSession, reorderTabs, toggleSessionMode etc. by making them scope-agnostic (check whether session has worktree_id or connection_id to know which map to update).
In src/main/ipc/database-handlers.ts:
ipcMain.handle('db:session:getByConnection', async (_event, connectionId: string) => {
return db.getSessionsByConnection(connectionId)
})
ipcMain.handle('db:session:getActiveByConnection', async (_event, connectionId: string) => {
return db.getActiveSessionsByConnection(connectionId)
})Add corresponding methods to database.ts.
Add to the Zustand persist middleware's partialize:
partialize: (state) => ({
activeSessionByWorktree: state.activeSessionByWorktree,
activeSessionByConnection: state.activeSessionByConnection
})src/renderer/src/stores/useSessionStore.ts-- connection session maps and actionssrc/main/ipc/database-handlers.ts-- new DB handlerssrc/main/db/database.ts-- new query methods
-
sessionsByConnectionandtabOrderByConnectionare maintained in parallel to worktree maps -
createConnectionSessioncreates a session withconnection_idset,worktree_idnull -
loadConnectionSessionsloads active sessions for a connection - Tab order and active session per connection are persisted
- Closing a connection session marks it completed and removes from tabs
- DB queries
getByConnectionandgetActiveByConnectionwork correctly - Existing worktree session behavior is completely unchanged
-
pnpm lintpasses -
pnpm testpasses
// test/worktree-connection/session-6/session-store-connections.test.ts
describe('Session 6: Session Store Connection Support', () => {
test('createConnectionSession sets connection_id and null worktree_id', () => {
// Mock window.db.session.create
// Call createConnectionSession('conn-1')
// Verify create called with { connection_id: 'conn-1', worktree_id: null }
})
test('loadConnectionSessions populates sessionsByConnection', () => {
// Mock getActiveByConnection returning 2 sessions
// Call loadConnectionSessions
// Verify sessionsByConnection.get('conn-1') has 2 entries
})
test('closing connection session removes from tabOrderByConnection', () => {
// Create session, add to tabs
// Close session
// Verify removed from tabOrderByConnection
})
test('activeSessionByConnection persists across store resets', () => {
// Set active connection session
// Verify it survives localStorage round-trip
})
test('existing worktree session methods are unaffected', () => {
// Create a worktree session
// Verify sessionsByWorktree still works correctly
// Verify sessionsByConnection is unaffected
})
})- Create
ConnectDialogcomponent that lets users pick worktrees from other projects - Show worktrees grouped by project with breed name + project name
- Support both "create new connection" and "add to existing connection" flows
- Wire up to worktree context menu
Create src/renderer/src/components/connections/ConnectDialog.tsx:
- A dialog/sheet component using shadcn
Dialog - Props:
sourceWorktreeId: string,open: boolean,onOpenChange: (open: boolean) => void - Lists all active worktrees from ALL projects except the source worktree's project
- Each item shows: checkbox, breed name, project name in parentheses
- Grouped by project with project name as group header
- "Connect" button at the bottom, disabled when no worktrees selected
- On submit: calls
useConnectionStore.createConnection([sourceWorktreeId, ...selectedIds])
If existing connections are found (the source worktree is already in some connections, or there are connections it could be added to), show them above the worktree list as quick options under a "Add to existing connection" section.
In src/renderer/src/components/worktrees/WorktreeItem.tsx, add a new context menu item:
<ContextMenuItem onClick={() => setConnectDialogOpen(true)}>
<Link className="h-4 w-4 mr-2" />
Connect to...
</ContextMenuItem>Add state: const [connectDialogOpen, setConnectDialogOpen] = useState(false)
Render <ConnectDialog> at the component level.
Create src/renderer/src/components/connections/index.ts exporting ConnectDialog.
src/renderer/src/components/connections/ConnectDialog.tsx-- new filesrc/renderer/src/components/connections/index.ts-- new filesrc/renderer/src/components/worktrees/WorktreeItem.tsx-- context menu item
- "Connect to..." appears in worktree context menu
- Dialog opens showing worktrees from other projects, grouped by project
- Each worktree shows breed name + project name
- Multiple worktrees can be selected via checkboxes
- "Connect" button is disabled until at least one worktree is checked
- Submitting creates a connection and closes the dialog
- Toast confirms connection creation with the breed name
- Dialog supports adding to existing connections (shown as options above the list)
-
pnpm lintpasses -
pnpm testpasses
- Right-click a worktree -- verify "Connect to..." menu item
- Click it -- verify dialog opens with worktrees from other projects
- Select one worktree + click Connect -- verify connection created, toast shown
- Open sidebar -- verify new connection appears in Connections section
- Right-click the same worktree again -- verify existing connection shown in dialog
// test/worktree-connection/session-7/connect-dialog.test.tsx
describe('Session 7: Connect Dialog', () => {
test('renders worktrees grouped by project', () => {
// Mock worktrees from 3 projects
// Render dialog with source from project 1
// Verify project 2 and 3 worktrees are shown
// Verify project 1 worktrees are NOT shown
})
test('Connect button is disabled when nothing selected', () => {
// Render dialog
// Verify Connect button is disabled
})
test('selecting a worktree enables the Connect button', () => {
// Render, click checkbox
// Verify Connect button is enabled
})
test('submitting calls createConnection with source + selected IDs', () => {
// Mock createConnection
// Select a worktree, click Connect
// Verify createConnection called with [sourceId, selectedId]
})
})- Create
ConnectionListcomponent for the sidebar "Connections" section - Create
ConnectionItemcomponent for individual connection rows - Add the section to the sidebar above the project list
- Support context menu with rename, add worktree, open in terminal/editor, copy path, delete
Create src/renderer/src/components/connections/ConnectionList.tsx:
- Renders a collapsible "CONNECTIONS" section header
- Only visible when
connections.length > 0 - Lists all active connections using
ConnectionItem - Loads connections on mount via
useConnectionStore.loadConnections()
Create src/renderer/src/components/connections/ConnectionItem.tsx:
- Shows connection name (breed name)
- Subtitle: project names joined by " + "
- Status indicator using
useWorktreeStatusStore(aggregated from connection's own sessions) - Click handler: calls
useConnectionStore.selectConnection(id) - Visual selected state: highlighted background when
selectedConnectionId === id - Context menu:
- Rename (inline editing, calls
useConnectionStore.renameConnection) - Add worktree (opens a picker or the ConnectDialog scoped to adding)
- Open in Terminal
- Open in Editor
- Copy Path
- Show in Finder
- Delete (with confirmation toast)
- Rename (inline editing, calls
In src/renderer/src/components/layout/Sidebar.tsx, render <ConnectionList /> above the existing project list.
Update src/renderer/src/components/connections/index.ts.
src/renderer/src/components/connections/ConnectionList.tsx-- new filesrc/renderer/src/components/connections/ConnectionItem.tsx-- new filesrc/renderer/src/components/connections/index.ts-- update barrelsrc/renderer/src/components/layout/Sidebar.tsx-- render ConnectionList
- "CONNECTIONS" section appears in sidebar above projects when connections exist
- Section is hidden when no connections exist
- Each connection shows breed name and project names subtitle
- Clicking a connection selects it (deselects any worktree)
- Selected connection has highlighted background
- Status indicators aggregate from the connection's own sessions
- Context menu works: rename, add worktree, open in terminal/editor, copy path, delete
- Inline rename edits the connection name
- Delete shows confirmation, then removes the connection
-
pnpm lintpasses -
pnpm testpasses
- Create a connection -- verify "CONNECTIONS" section appears in sidebar
- Verify connection shows breed name and "frontend + backend" subtitle
- Click the connection -- verify it highlights, worktree deselects
- Right-click -- verify context menu with all actions
- Rename via context menu -- verify name updates
- Delete via context menu -- verify connection removed, section disappears if last
- Delete all connections -- verify "CONNECTIONS" section disappears entirely
// test/worktree-connection/session-8/sidebar-connections.test.tsx
describe('Session 8: Sidebar Connections', () => {
test('ConnectionList renders when connections exist', () => {
// Mock 2 connections in store
// Render ConnectionList
// Verify 2 ConnectionItem components rendered
})
test('ConnectionList is hidden when no connections', () => {
// Mock empty connections
// Render ConnectionList
// Verify nothing rendered (or section header hidden)
})
test('ConnectionItem shows breed name and project subtitle', () => {
// Render ConnectionItem with members from 'frontend' and 'backend'
// Verify breed name shown
// Verify subtitle contains 'frontend + backend'
})
test('clicking ConnectionItem selects it and deselects worktree', () => {
// Mock selectConnection and selectWorktree
// Click ConnectionItem
// Verify selectConnection called
})
test('context menu delete removes connection', () => {
// Mock deleteConnection
// Open context menu, click Delete
// Verify deleteConnection called
})
})- Update
MainPaneto render session tabs and view for the selected connection - Update
SessionViewto resolve the connection path whenconnection_idis set - Update
SessionTabsto read from connection-scoped session lists - Hide git UI (status, push/pull, PR) when a connection is selected
In src/renderer/src/components/layout/MainPane.tsx, add a check for selectedConnectionId:
const selectedConnectionId = useConnectionStore((s) => s.selectedConnectionId)
// If a connection is selected, render connection sessions
if (selectedConnectionId) {
return <ConnectionSessionView connectionId={selectedConnectionId} />
}
// Otherwise, existing worktree logicThe ConnectionSessionView is either a new wrapper component or the existing SessionView with a connectionId prop instead of worktreeId.
In SessionView.tsx where the worktree path is resolved for OpenCode connect:
// Existing: resolve worktree path
const worktreePath = session.worktree_id
? (await window.db.worktree.get(session.worktree_id))?.path
: null
// New: also check for connection path
const connectionPath = session.connection_id
? (await window.connectionOps.get(session.connection_id))?.path
: null
const workingDirectory = worktreePath || connectionPathUse workingDirectory for window.opencodeOps.connect(workingDirectory, sessionId).
Make SessionTabs accept either a worktreeId or connectionId prop. Read from the appropriate session list:
const sessions = worktreeId
? useSessionStore((s) => s.sessionsByWorktree.get(worktreeId))
: useSessionStore((s) => s.sessionsByConnection.get(connectionId!))
const tabOrder = worktreeId
? useSessionStore((s) => s.tabOrderByWorktree.get(worktreeId))
: useSessionStore((s) => s.tabOrderByConnection.get(connectionId!))In Header.tsx and the right sidebar, check if a connection is selected and hide git-specific UI:
const selectedConnectionId = useConnectionStore((s) => s.selectedConnectionId)
const isConnectionMode = !!selectedConnectionId
// Hide: git status, push/pull, PR button, merge dropdown
// Show: connection name, session tabs, model selectorsrc/renderer/src/components/layout/MainPane.tsx-- connection routingsrc/renderer/src/components/sessions/SessionView.tsx-- path resolutionsrc/renderer/src/components/sessions/SessionTabs.tsx-- scope-agnostic tabssrc/renderer/src/components/layout/Header.tsx-- hide git UI in connection mode
- Selecting a connection shows its sessions in the main pane
- Session tabs read from
sessionsByConnectionwhen a connection is active - Creating a "+" session in connection mode creates a connection session
-
SessionViewconnects to OpenCode using the connection folder path - The AI agent sees the symlinked directory structure
- Git UI (status, push/pull, PR, merge) is hidden when a connection is selected
- Header shows the connection name instead of worktree/branch info
- Switching between worktree and connection modes works seamlessly
-
pnpm lintpasses -
pnpm testpasses
- Create a connection between frontend + backend worktrees
- Click the connection in the sidebar
- Verify session tabs area appears (empty, with "+" button)
- Click "+" to create a session -- verify session initializes
- Send a prompt -- verify the agent can see both repos (ask it to list directories)
- Verify git UI (push/pull, PR, merge) is hidden
- Click a worktree in the sidebar -- verify back to normal worktree mode
- Click the connection again -- verify session is still there (persisted)
// test/worktree-connection/session-9/session-view-connections.test.tsx
describe('Session 9: SessionView & MainPane Integration', () => {
test('MainPane renders connection session view when connection selected', () => {
// Mock selectedConnectionId
// Render MainPane
// Verify connection session view rendered (not worktree view)
})
test('SessionView resolves connection path for OpenCode connect', () => {
// Mock session with connection_id set, worktree_id null
// Mock window.connectionOps.get returning { path: '/path/to/connection' }
// Verify opencodeOps.connect called with connection path
})
test('SessionTabs reads from sessionsByConnection', () => {
// Mock connectionId prop
// Render SessionTabs
// Verify it reads from sessionsByConnection, not sessionsByWorktree
})
test('Header hides git UI when connection is selected', () => {
// Mock selectedConnectionId
// Render Header
// Verify push/pull/PR/merge elements are not rendered
})
test('switching from connection to worktree restores git UI', () => {
// Select connection, then select worktree
// Verify git UI reappears
})
})- Hook worktree archival into connection cleanup
- Verify all features work end-to-end
- Run full test suite and lint
- Test edge cases
In src/renderer/src/stores/useWorktreeStore.ts, in the archiveWorktree action, after the existing archive logic but before state updates:
// Clean up any connections referencing this worktree
try {
await window.connectionOps.removeWorktreeFromAll(worktreeId)
// Reload connections to reflect the change
await useConnectionStore.getState().loadConnections()
} catch {
// Non-critical -- log but don't block archive
}This calls the connection:removeWorktreeFromAll handler which removes the symlink and member row from every connection containing this worktree, and deletes connections that become empty.
pnpm test
pnpm lintFix any failures.
Connection creation:
- Right-click worktree -> "Connect to..." -> select worktree from another project -> Connect
- Verify folder created at
~/.hive/connections/{breedName}/with symlinks - Verify
AGENTS.mdis generated with correct content - Verify connection appears in sidebar
Session in connection:
- Select connection -> create session -> send a prompt
- Verify agent can see both repos
- Close session -> reopen from history -> verify reconnect works
Member management:
- Add a third worktree to an existing connection -> verify symlink + AGENTS.md updated
- Remove a worktree from a connection -> verify symlink removed + AGENTS.md updated
- Remove last worktree -> verify connection deleted
Worktree archive cascade:
- Archive a worktree that belongs to a connection
- Verify symlink removed from connection automatically
- Verify connection survives with remaining members
- Archive the last member -> verify connection is deleted
Selection deconfliction:
- Click worktree -> click connection -> click worktree
- Verify only one is selected at a time
- Verify main pane switches correctly
Edge cases:
- Create connection when
~/.hive/connections/doesn't exist -> verify it's created - Two connections referencing the same worktree -> both work
- Broken symlink (worktree deleted outside Hive) -> connection still loads, warning shown
- All existing worktree operations work as before
- All existing session operations work as before
- All existing git operations work as before
src/renderer/src/stores/useWorktreeStore.ts-- archive cascade- All files from Sessions 1-9
- Archiving a worktree removes its symlinks from all connections
- Archiving the last member of a connection deletes the connection
-
pnpm testpasses with zero failures -
pnpm lintpasses with zero errors - Connection creation end-to-end works (dir + symlinks + AGENTS.md + DB)
- Sessions in connections work (create, send prompt, agent sees both repos)
- Member management works (add, remove, last-member deletion)
- Selection deconfliction works (worktree vs connection)
- Git UI is hidden when a connection is selected
- No regressions in existing worktree/session/git features
// test/worktree-connection/session-10/archive-cascade-integration.test.ts
describe('Session 10: Archive Cascade & Integration', () => {
test('archiving a worktree calls removeWorktreeFromAll', () => {
// Mock archiveWorktree flow
// Verify window.connectionOps.removeWorktreeFromAll called with worktreeId
})
test('archiving a worktree reloads connections', () => {
// Mock archive flow
// Verify loadConnections called after removeWorktreeFromAll
})
test('connection survives when one of multiple members is archived', () => {
// Connection with 2 members
// Archive worktree for member 1
// Verify connection still exists with 1 member
})
test('connection is deleted when last member is archived', () => {
// Connection with 1 member
// Archive that worktree
// Verify connection is deleted
})
test('full lifecycle: create connection -> session -> archive -> cleanup', () => {
// Create connection between 2 worktrees
// Create a session in the connection
// Archive one worktree
// Verify connection has 1 member, session still exists (orphaned)
// Archive the other worktree
// Verify connection is deleted
})
test('worktree in multiple connections is cleaned up from all', () => {
// Add same worktree to 2 different connections
// Archive that worktree
// Verify it's removed from both connections
})
})