[1] Worktrees
+ [1] Worktrees + PRs
Folder
@@ -31,6 +30,19 @@
+
+ [1] Plans + Issues
+
+
+
+ Title
+ Project
+ Status
+ Issue
+
+
+
-
-
-
- Plans (press o to open issue, Esc to close)
- -
diff --git a/web/playwright.config.js b/web/playwright.config.js
index 63a695a..aeaada3 100644
--- a/web/playwright.config.js
+++ b/web/playwright.config.js
@@ -9,7 +9,7 @@ module.exports = defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
- baseURL: 'http://localhost:8080',
+ baseURL: 'http://localhost:8374',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
@@ -20,9 +20,9 @@ module.exports = defineConfig({
},
],
webServer: {
- command: 'cd .. && go run ./cmd/bearing daemon start --foreground --port 8080',
- url: 'http://localhost:8080',
- reuseExistingServer: !process.env.CI,
+ command: 'cd .. && go run ./cmd/bearing daemon start --foreground',
+ url: 'http://localhost:8374',
+ reuseExistingServer: true,
timeout: 30000,
},
});
diff --git a/web/style.css b/web/style.css
index f882996..a7553e6 100644
--- a/web/style.css
+++ b/web/style.css
@@ -151,6 +151,16 @@ body {
flex: 1;
}
+/* Plans Panel */
+#plans-panel {
+ flex: 1;
+}
+
+/* View panel visibility */
+.view-panel.hidden {
+ display: none;
+}
+
/* Lists */
.list {
list-style: none;
@@ -270,12 +280,24 @@ body {
font-weight: bold;
}
-/* Table columns */
+/* Table columns - Worktrees */
.col-folder { flex: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-branch { flex: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-status { flex: 1; text-align: center; }
.col-pr { flex: 0.8; text-align: center; }
+/* Table columns - Plans */
+.col-title { flex: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.col-project { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
+.col-plan-status { flex: 0.8; text-align: center; }
+.col-issue { flex: 0.6; text-align: center; color: var(--accent-cyan); }
+
+/* Plan status badges */
+.plan-status-draft { color: var(--text-dim); }
+.plan-status-active { color: var(--accent-green); }
+.plan-status-done { color: var(--accent-blue); }
+.plan-status-archived { color: var(--text-dim); opacity: 0.5; }
+
/* Status indicators */
.status-dirty { color: var(--accent-yellow); }
.status-clean { color: var(--accent-green); }
diff --git a/web/tests/e2e/keyboard.test.js b/web/tests/e2e/keyboard.test.js
index babc1cc..abbf06b 100644
--- a/web/tests/e2e/keyboard.test.js
+++ b/web/tests/e2e/keyboard.test.js
@@ -16,14 +16,16 @@ const mockWorktrees = [
];
const mockPlans = [
- { title: 'TUI improvements', project: 'bearing', status: 'in_progress', issue: 42 },
- { title: 'Add compass component', project: 'sailkit', status: 'pending', issue: 15 },
+ { id: 'abc123', title: 'TUI improvements', project: 'bearing', status: 'active', issue: 42 },
+ { id: 'ghi789', title: 'Web dashboard', project: 'bearing', status: 'draft', issue: 66 },
+ { id: 'def456', title: 'Add compass component', project: 'sailkit', status: 'draft', issue: 15 },
];
test.describe('Keyboard - j/k Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
@@ -109,6 +111,7 @@ test.describe('Keyboard - h/l Panel Focus', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
@@ -158,44 +161,40 @@ test.describe('Keyboard - Number Keys for Views', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
});
- test('1 key switches to worktrees view', async ({ page }) => {
+ test('1 key switches to operational view (Worktrees+PRs)', async ({ page }) => {
// First switch away
await page.keyboard.press('2');
- await expect(page.locator('.tab[data-view="issues"]')).toHaveClass(/active/);
+ await expect(page.locator('.tab[data-view="planning"]')).toHaveClass(/active/);
// Press 1 to go back
await page.keyboard.press('1');
- await expect(page.locator('.tab[data-view="worktrees"]')).toHaveClass(/active/);
- await expect(page.locator('#main-container')).toHaveCSS('display', 'flex');
+ await expect(page.locator('.tab[data-view="operational"]')).toHaveClass(/active/);
+ await expect(page.locator('#worktrees-panel')).not.toHaveClass(/hidden/);
+ await expect(page.locator('#plans-panel')).toHaveClass(/hidden/);
});
- test('2 key switches to issues view', async ({ page }) => {
+ test('2 key switches to planning view (Plans+Issues)', async ({ page }) => {
await page.keyboard.press('2');
- await expect(page.locator('.tab[data-view="issues"]')).toHaveClass(/active/);
- await expect(page.locator('#main-container')).toHaveCSS('display', 'none');
- });
-
- test('3 key switches to PRs view', async ({ page }) => {
- await page.keyboard.press('3');
-
- await expect(page.locator('.tab[data-view="prs"]')).toHaveClass(/active/);
- await expect(page.locator('#placeholder-view')).toContainText('Pull Requests');
+ await expect(page.locator('.tab[data-view="planning"]')).toHaveClass(/active/);
+ await expect(page.locator('#worktrees-panel')).toHaveClass(/hidden/);
+ await expect(page.locator('#plans-panel')).not.toHaveClass(/hidden/);
});
test('number keys update localStorage', async ({ page }) => {
- await page.keyboard.press('3');
+ await page.keyboard.press('2');
const savedState = await page.evaluate(() => {
return JSON.parse(localStorage.getItem('bearing-state') || '{}');
});
- expect(savedState.currentView).toBe('prs');
+ expect(savedState.currentView).toBe('planning');
});
});
@@ -203,26 +202,23 @@ test.describe('Keyboard - Tab Cycling', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
});
test('Tab key cycles through views', async ({ page }) => {
- // Start on worktrees
- await expect(page.locator('.tab[data-view="worktrees"]')).toHaveClass(/active/);
-
- // Tab -> issues
- await page.keyboard.press('Tab');
- await expect(page.locator('.tab[data-view="issues"]')).toHaveClass(/active/);
+ // Start on operational
+ await expect(page.locator('.tab[data-view="operational"]')).toHaveClass(/active/);
- // Tab -> prs
+ // Tab -> planning
await page.keyboard.press('Tab');
- await expect(page.locator('.tab[data-view="prs"]')).toHaveClass(/active/);
+ await expect(page.locator('.tab[data-view="planning"]')).toHaveClass(/active/);
- // Tab -> back to worktrees (cycle)
+ // Tab -> back to operational (cycle)
await page.keyboard.press('Tab');
- await expect(page.locator('.tab[data-view="worktrees"]')).toHaveClass(/active/);
+ await expect(page.locator('.tab[data-view="operational"]')).toHaveClass(/active/);
});
test('Tab cycling persists state', async ({ page }) => {
@@ -232,7 +228,7 @@ test.describe('Keyboard - Tab Cycling', () => {
return JSON.parse(localStorage.getItem('bearing-state') || '{}');
});
- expect(savedState.currentView).toBe('issues');
+ expect(savedState.currentView).toBe('planning');
});
});
@@ -240,6 +236,7 @@ test.describe('Keyboard - Help Modal', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
@@ -273,11 +270,11 @@ test.describe('Keyboard - Help Modal', () => {
// Try to change view - should not work
await page.keyboard.press('2');
- await expect(page.locator('.tab[data-view="worktrees"]')).toHaveClass(/active/);
+ await expect(page.locator('.tab[data-view="operational"]')).toHaveClass(/active/);
});
});
-test.describe('Keyboard - Plans Modal', () => {
+test.describe('Keyboard - Plans View Navigation', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
@@ -285,44 +282,33 @@ test.describe('Keyboard - Plans Modal', () => {
await page.route('**/api/events', route => route.abort());
await page.goto('/');
+ // Switch to planning view
+ await page.keyboard.press('2');
});
- test('p key opens plans modal', async ({ page }) => {
- await page.keyboard.press('p');
+ test('j/k navigates plans in planning view', async ({ page }) => {
+ await page.waitForSelector('#plans-rows .table-row');
- await expect(page.locator('#plans-modal')).not.toHaveClass(/hidden/);
- });
-
- test('j/k navigates plans in modal', async ({ page }) => {
- await page.keyboard.press('p');
- await page.waitForSelector('#plans-list .list-item');
+ // Focus plans table
+ await page.keyboard.press('l');
- // First plan selected
- await expect(page.locator('#plans-list .list-item.selected')).toContainText('TUI improvements');
+ // First plan should be selected
+ await expect(page.locator('#plans-rows .table-row.selected')).toHaveAttribute('data-index', '0');
// Navigate down
await page.keyboard.press('j');
- await expect(page.locator('#plans-list .list-item.selected')).toContainText('Add compass');
+ await expect(page.locator('#plans-rows .table-row.selected')).toHaveAttribute('data-index', '1');
// Navigate back up
await page.keyboard.press('k');
- await expect(page.locator('#plans-list .list-item.selected')).toContainText('TUI improvements');
+ await expect(page.locator('#plans-rows .table-row.selected')).toHaveAttribute('data-index', '0');
});
- test('Escape closes plans modal', async ({ page }) => {
- await page.keyboard.press('p');
- await expect(page.locator('#plans-modal')).not.toHaveClass(/hidden/);
+ test('details panel shows selected plan info', async ({ page }) => {
+ await page.waitForSelector('#plans-rows .table-row');
- await page.keyboard.press('Escape');
- await expect(page.locator('#plans-modal')).toHaveClass(/hidden/);
- });
-
- test('p key also closes plans modal', async ({ page }) => {
- await page.keyboard.press('p');
- await expect(page.locator('#plans-modal')).not.toHaveClass(/hidden/);
-
- await page.keyboard.press('p');
- await expect(page.locator('#plans-modal')).toHaveClass(/hidden/);
+ // Should show first plan's details
+ await expect(page.locator('#details-content')).toContainText('TUI improvements');
});
});
@@ -334,6 +320,7 @@ test.describe('Keyboard - Refresh', () => {
route.fulfill({ json: mockProjects });
});
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
diff --git a/web/tests/e2e/navigation.test.js b/web/tests/e2e/navigation.test.js
index 90057ca..f83d2bb 100644
--- a/web/tests/e2e/navigation.test.js
+++ b/web/tests/e2e/navigation.test.js
@@ -17,6 +17,10 @@ const mockWorktrees = [
{ repo: 'surfdeeper', folder: 'surfdeeper', branch: 'main', base: true, dirty: false, unpushed: 0, prState: null },
];
+const mockPlans = [
+ { id: 'abc123', title: 'TUI improvements', project: 'bearing', status: 'active', issue: 42 },
+];
+
test.describe('Navigation - Project Selection', () => {
test.beforeEach(async ({ page }) => {
// Intercept API calls with mock data
@@ -26,6 +30,9 @@ test.describe('Navigation - Project Selection', () => {
await page.route('**/api/worktrees', route => {
route.fulfill({ json: mockWorktrees });
});
+ await page.route('**/api/plans', route => {
+ route.fulfill({ json: mockPlans });
+ });
await page.route('**/api/events', route => {
// SSE endpoint - just hang
route.abort();
@@ -82,6 +89,7 @@ test.describe('Navigation - Worktree Selection', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
@@ -116,7 +124,7 @@ test.describe('Navigation - Worktree Selection', () => {
await expect(detailsContent).toContainText('Uncommitted');
});
- test('worktree index persists in localStorage', async ({ page }) => {
+ test('worktree folder persists in localStorage', async ({ page }) => {
// Click third row
await page.click('#worktree-rows .table-row:nth-child(3)');
@@ -124,7 +132,8 @@ test.describe('Navigation - Worktree Selection', () => {
return JSON.parse(localStorage.getItem('bearing-state') || '{}');
});
- expect(savedState.worktreeIndex).toBe(2);
+ // The folder name is persisted (not the index) so selection survives sorting changes
+ expect(savedState.selectedWorktreeFolder).toBeTruthy();
});
});
@@ -132,53 +141,44 @@ test.describe('Navigation - Tab Views', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
});
test('clicking tab changes active view', async ({ page }) => {
- // Click Issues tab
- await page.click('.tab[data-view="issues"]');
+ // Click Planning tab
+ await page.click('.tab[data-view="planning"]');
// Verify tab is active
- await expect(page.locator('.tab[data-view="issues"]')).toHaveClass(/active/);
- await expect(page.locator('.tab[data-view="worktrees"]')).not.toHaveClass(/active/);
-
- // Main container should be hidden
- await expect(page.locator('#main-container')).toHaveCSS('display', 'none');
- });
-
- test('clicking PRs tab shows placeholder', async ({ page }) => {
- await page.click('.tab[data-view="prs"]');
-
- // PRs tab should be active
- await expect(page.locator('.tab[data-view="prs"]')).toHaveClass(/active/);
+ await expect(page.locator('.tab[data-view="planning"]')).toHaveClass(/active/);
+ await expect(page.locator('.tab[data-view="operational"]')).not.toHaveClass(/active/);
- // Placeholder should be visible
- const placeholder = page.locator('#placeholder-view');
- await expect(placeholder).toBeVisible();
- await expect(placeholder).toContainText('Pull Requests');
+ // Worktrees panel should be hidden, plans panel visible
+ await expect(page.locator('#worktrees-panel')).toHaveClass(/hidden/);
+ await expect(page.locator('#plans-panel')).not.toHaveClass(/hidden/);
});
- test('switching back to worktrees shows main content', async ({ page }) => {
- // Go to issues
- await page.click('.tab[data-view="issues"]');
- await expect(page.locator('#main-container')).toHaveCSS('display', 'none');
+ test('switching back to operational shows worktrees', async ({ page }) => {
+ // Go to planning
+ await page.click('.tab[data-view="planning"]');
+ await expect(page.locator('#worktrees-panel')).toHaveClass(/hidden/);
- // Go back to worktrees
- await page.click('.tab[data-view="worktrees"]');
- await expect(page.locator('#main-container')).toHaveCSS('display', 'flex');
+ // Go back to operational
+ await page.click('.tab[data-view="operational"]');
+ await expect(page.locator('#worktrees-panel')).not.toHaveClass(/hidden/);
+ await expect(page.locator('#plans-panel')).toHaveClass(/hidden/);
});
test('current view persists in localStorage', async ({ page }) => {
- await page.click('.tab[data-view="prs"]');
+ await page.click('.tab[data-view="planning"]');
const savedState = await page.evaluate(() => {
return JSON.parse(localStorage.getItem('bearing-state') || '{}');
});
- expect(savedState.currentView).toBe('prs');
+ expect(savedState.currentView).toBe('planning');
});
});
@@ -186,6 +186,7 @@ test.describe('Navigation - Persistence on Reload', () => {
test('selected project survives page reload', async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
@@ -206,18 +207,19 @@ test.describe('Navigation - Persistence on Reload', () => {
test('current view survives page reload', async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: mockPlans }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
- // Switch to PRs view
- await page.click('.tab[data-view="prs"]');
- await expect(page.locator('.tab[data-view="prs"]')).toHaveClass(/active/);
+ // Switch to Planning view
+ await page.click('.tab[data-view="planning"]');
+ await expect(page.locator('.tab[data-view="planning"]')).toHaveClass(/active/);
// Reload
await page.reload();
- // PRs should still be active
- await expect(page.locator('.tab[data-view="prs"]')).toHaveClass(/active/);
+ // Planning should still be active
+ await expect(page.locator('.tab[data-view="planning"]')).toHaveClass(/active/);
});
});
diff --git a/web/tests/e2e/sorting.test.js b/web/tests/e2e/sorting.test.js
index 497115a..3b51f2c 100644
--- a/web/tests/e2e/sorting.test.js
+++ b/web/tests/e2e/sorting.test.js
@@ -18,6 +18,7 @@ test.describe('Sorting - Column Headers', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: [] }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
@@ -70,10 +71,10 @@ test.describe('Sorting - Column Headers', () => {
});
test('clicking Status header sorts by status', async ({ page }) => {
- await page.click('[data-sort="status"]');
+ await page.click('#worktree-table [data-sort="status"]');
// Verify header has sort indicator
- await expect(page.locator('[data-sort="status"]')).toHaveClass(/sort-asc/);
+ await expect(page.locator('#worktree-table [data-sort="status"]')).toHaveClass(/sort-asc/);
// Status sort order: dirty first, then unpushed, then clean
const rows = page.locator('#worktree-rows .table-row');
@@ -113,6 +114,7 @@ test.describe('Sorting - Persistence', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: [] }));
await page.route('**/api/events', route => route.abort());
});
@@ -172,22 +174,24 @@ test.describe('Sorting - Selection Interaction', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: [] }));
await page.route('**/api/events', route => route.abort());
await page.goto('/');
await page.waitForSelector('#worktree-rows .table-row');
});
- test('sorting preserves worktree index position', async ({ page }) => {
- // Select second row
+ test('sorting preserves selected worktree (not index)', async ({ page }) => {
+ // Select second row and get its folder name
await page.click('#worktree-rows .table-row:nth-child(2)');
- await expect(page.locator('#worktree-rows .table-row.selected')).toHaveAttribute('data-index', '1');
+ const selectedFolder = await page.locator('#worktree-rows .table-row.selected').getAttribute('data-folder');
+ expect(selectedFolder).toBeTruthy();
// Sort by folder
await page.click('[data-sort="folder"]');
- // Selection should still be at index 1 (same position in list)
- await expect(page.locator('#worktree-rows .table-row.selected')).toHaveAttribute('data-index', '1');
+ // Same worktree should still be selected (folder preserved, index may change)
+ await expect(page.locator('#worktree-rows .table-row.selected')).toHaveAttribute('data-folder', selectedFolder);
});
test('details panel updates after sorting', async ({ page }) => {
@@ -208,14 +212,15 @@ test.describe('Sorting - Default Order', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/projects', route => route.fulfill({ json: mockProjects }));
await page.route('**/api/worktrees', route => route.fulfill({ json: mockWorktrees }));
+ await page.route('**/api/plans', route => route.fulfill({ json: [] }));
await page.route('**/api/events', route => route.abort());
});
test('default sort prioritizes OPEN PRs', async ({ page }) => {
- // Clear localStorage to ensure default
- await page.evaluate(() => localStorage.clear());
-
await page.goto('/');
+ // Clear localStorage and reload to ensure default sort
+ await page.evaluate(() => localStorage.clear());
+ await page.reload();
await page.waitForSelector('#worktree-rows .table-row');
// First worktree with PR should be OPEN