diff --git a/apps/api/src/routes/content.test.ts b/apps/api/src/routes/content.test.ts index 8b4e2e8..9cd8a1e 100644 --- a/apps/api/src/routes/content.test.ts +++ b/apps/api/src/routes/content.test.ts @@ -113,7 +113,7 @@ describe('GET /api/content/inflight', () => { expect(data[0]).toEqual({ pr: 42, branch: 'idea/abc-draft', title: 'Draft Idea' }); }); - it('includes id when cache has matching branch entry', async () => { + it('includes id and type when cache has matching branch entry', async () => { vi.mocked(mockCache.findByBranch).mockReturnValueOnce(ENTRIES[0]); const res = await authed('/api/content/inflight'); const data = (await res.json()) as Array<{ @@ -121,8 +121,10 @@ describe('GET /api/content/inflight', () => { branch: string; title: string; id?: string; + type?: string; }>; expect(data[0].id).toBe('idea-1'); + expect(data[0].type).toBe('idea'); }); }); diff --git a/apps/api/src/routes/content.ts b/apps/api/src/routes/content.ts index 6688402..11baba6 100644 --- a/apps/api/src/routes/content.ts +++ b/apps/api/src/routes/content.ts @@ -38,7 +38,7 @@ export function contentRoutes(cache: IndexCache, github: GitHubClient) { pr: pr.number, branch: pr.head.ref, title: pr.title, - ...(entry ? { id: entry.id } : {}), + ...(entry ? { id: entry.id, type: entry.type } : {}), }; }) ); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 33293dd..fc99c60 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -37,13 +37,17 @@ export interface InFlightItem { branch: string; title: string; id?: string; + type?: ContentType; } export const api = { me: () => req<{ email: string; name: string } | null>('/auth/me'), logout: () => req('/auth/logout', { method: 'POST' }), content: (params?: { type?: string; status?: string; q?: string }) => { - const qs = new URLSearchParams(params as Record).toString(); + const defined = Object.fromEntries( + Object.entries(params ?? {}).filter(([, v]) => v !== undefined) + ); + const qs = new URLSearchParams(defined as Record).toString(); return req(`/api/content${qs ? `?${qs}` : ''}`); }, contentById: (id: string) => req(`/api/content/${id}`), @@ -61,7 +65,21 @@ export const api = { body: JSON.stringify(body), }), commit: (id: string) => req(`/api/content/${id}/commit`, { method: 'POST' }), + park: (id: string) => req(`/api/content/${id}/park`, { method: 'POST' }), dismiss: (id: string) => req(`/api/content/${id}/dismiss`, { method: 'POST' }), + promote: (id: string, title: string, summary: string) => + req<{ id: string; pr: number; branch: string }>('/api/capture', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'plan', + title: `Plan: ${title}`, + tags: [], + summary, + promoted_from: [id], + body: `## Goal\n${summary || title}\n\n## Steps\n- (to be refined)\n\n## Success criteria\n- (to be refined)`, + }), + }), chat: (messages: unknown[], query?: string, relatedToId?: string) => req<{ reply: string; context: Array<{ id: string; title: string }> }>('/api/chat', { method: 'POST', diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index 0bab407..3ba8bd4 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -6,7 +6,9 @@ import EntryList from '$lib/components/EntryList.svelte'; import { onMount } from 'svelte'; const TYPES = ['all', 'idea', 'plan', 'discussion', 'solution', 'insight']; +const STATUSES = ['all', 'draft', 'active', 'parked', 'promoted', 'done', 'dismissed']; let activeType = 'all'; +let activeStatus = 'all'; let query = ''; let entries: IndexEntry[] = []; let inflightCount = 0; @@ -14,7 +16,11 @@ let searchTimer: ReturnType | null = null; async function load() { const [items, inflight] = await Promise.all([ - api.content({ type: activeType === 'all' ? undefined : activeType, q: query || undefined }), + api.content({ + type: activeType === 'all' ? undefined : activeType, + status: activeStatus === 'all' ? undefined : activeStatus, + q: query || undefined, + }), api.inflight(), ]); entries = items; @@ -51,7 +57,7 @@ function onSelect(entry: IndexEntry) { /> -
+
{#each TYPES as t}
+
+ {#each STATUSES as s} + + {/each} +
+
diff --git a/apps/web/src/routes/chat/[id]/+page.svelte b/apps/web/src/routes/chat/[id]/+page.svelte index 77382ba..2c19c01 100644 --- a/apps/web/src/routes/chat/[id]/+page.svelte +++ b/apps/web/src/routes/chat/[id]/+page.svelte @@ -10,6 +10,8 @@ let messages: Array<{ role: 'user' | 'assistant'; content: string }> = []; let input = ''; let loading = false; let finishing = false; +let parking = false; +let promoting = false; let contextTitles: string[] = []; let error = ''; @@ -44,6 +46,31 @@ async function send() { } } +async function park() { + parking = true; + error = ''; + try { + await api.park(id); + goto('/'); + } catch { + error = 'Failed to park. Please try again.'; + parking = false; + } +} + +async function promote() { + if (!item) return; + promoting = true; + error = ''; + try { + const { id: planId } = await api.promote(id, item.title, item.summary); + goto(`/chat/${planId}`); + } catch { + error = 'Failed to promote. Please try again.'; + promoting = false; + } +} + async function finish() { if (!confirm('Commit this session to GitHub?')) return; finishing = true; @@ -65,8 +92,14 @@ async function finish() {
{item?.title ?? '...'} - {#if item?.pr} - + {#if item} + {#if item.type === 'idea'} + + {/if} + + {#if item.pr} + + {/if} {/if}
diff --git a/apps/web/src/routes/chat/[id]/page.test.ts b/apps/web/src/routes/chat/[id]/page.test.ts index 38aead5..c4058b7 100644 --- a/apps/web/src/routes/chat/[id]/page.test.ts +++ b/apps/web/src/routes/chat/[id]/page.test.ts @@ -89,6 +89,55 @@ describe('/chat/[id]', () => { await waitFor(() => expect(calls).toEqual(['summarise', 'patch', 'commit'])); }); + test('shows Park button when item is loaded', async () => { + render(Page); + await waitFor(() => expect(screen.getByRole('button', { name: /park/i })).toBeInTheDocument()); + }); + + test('shows Promote button for idea type', async () => { + render(Page); + await waitFor(() => expect(screen.getByRole('button', { name: /plan/i })).toBeInTheDocument()); + }); + + test('does not show Promote button for plan type', async () => { + server.use( + http.get('http://localhost:8744/api/content/test-id', () => + HttpResponse.json({ + id: 'test-id', + type: 'plan', + title: 'A plan', + status: 'draft', + tags: [], + summary: '', + created: '2026-01-01', + updated: '2026-01-01', + path: 'plans/a-plan.md', + body: '', + }) + ) + ); + render(Page); + await waitFor(() => expect(screen.getByRole('button', { name: /park/i })).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: /plan/i })).toBeNull(); + }); + + test('Park button calls park endpoint and navigates home', async () => { + const { goto: mockGoto } = await import('$app/navigation'); + const parkCalled = vi.fn(); + server.use( + http.post('http://localhost:8744/api/content/test-id/park', () => { + parkCalled(); + return HttpResponse.json({ ok: true }); + }) + ); + const user = userEvent.setup(); + render(Page); + await waitFor(() => screen.getByRole('button', { name: /park/i })); + await user.click(screen.getByRole('button', { name: /park/i })); + await waitFor(() => expect(parkCalled).toHaveBeenCalled()); + expect(mockGoto).toHaveBeenCalledWith('/'); + }); + test('shows error message when chat send fails', async () => { server.use( http.post('http://localhost:8744/api/chat', () => diff --git a/apps/web/src/routes/page.stories.svelte b/apps/web/src/routes/page.stories.svelte new file mode 100644 index 0000000..09fdb6c --- /dev/null +++ b/apps/web/src/routes/page.stories.svelte @@ -0,0 +1,102 @@ + + + + + + + HttpResponse.json([ + { + id: '01JIDEA1', + type: 'idea', + title: 'Build a focus system', + status: 'active', + tags: ['productivity'], + summary: 'An idea for focusing better', + created: '2026-03-01T00:00:00Z', + updated: '2026-03-01T00:00:00Z', + path: 'ideas/focus.md', + }, + { + id: '01JPLAN1', + type: 'plan', + title: 'Plan: Refactor API', + status: 'parked', + tags: ['api', 'tech'], + summary: 'Structured plan to refactor the API layer', + created: '2026-03-02T00:00:00Z', + updated: '2026-03-02T00:00:00Z', + path: 'plans/api-refactor.md', + }, + { + id: '01JIDEA2', + type: 'idea', + title: 'Add dark mode', + status: 'done', + tags: ['ui'], + summary: 'Ship dark mode toggle', + created: '2026-03-03T00:00:00Z', + updated: '2026-03-03T00:00:00Z', + path: 'ideas/dark-mode.md', + }, + ]) + ), + ], + }, + }} +/> + + + HttpResponse.json([ + { + id: '01JPLAN1', + type: 'plan', + title: 'Plan: Refactor API', + status: 'parked', + tags: ['api'], + summary: 'Structured plan to refactor the API layer', + created: '2026-03-02T00:00:00Z', + updated: '2026-03-02T00:00:00Z', + path: 'plans/api-refactor.md', + }, + ]) + ), + ], + }, + }} +/> + + HttpResponse.json([])), + http.get('http://localhost:8744/api/content/inflight', () => + HttpResponse.json([ + { pr: 7, branch: 'idea/abc', title: 'Draft idea' }, + { pr: 8, branch: 'idea/xyz', title: 'Another draft' }, + ]) + ), + ], + }, + }} +/> diff --git a/apps/web/src/routes/page.test.ts b/apps/web/src/routes/page.test.ts new file mode 100644 index 0000000..4d6ffa5 --- /dev/null +++ b/apps/web/src/routes/page.test.ts @@ -0,0 +1,53 @@ +import { render, screen, waitFor, within } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, test } from 'vitest'; +import { server } from '../tests/server'; +import Page from './+page.svelte'; + +const BASE = 'http://localhost:8744'; + +describe('/ browse', () => { + test('renders status filter row with all statuses', async () => { + render(Page); + const statusFilter = await screen.findByRole('group', { name: /status filter/i }).catch(() => + // fallback: find the aria-label div + screen.findByLabelText(/status filter/i) + ); + expect(within(statusFilter).getByRole('button', { name: 'parked' })).toBeInTheDocument(); + expect(within(statusFilter).getByRole('button', { name: 'dismissed' })).toBeInTheDocument(); + expect(within(statusFilter).getByRole('button', { name: 'all' })).toBeInTheDocument(); + }); + + test('selecting a status filter reloads content with that status', async () => { + let capturedUrl = ''; + server.use( + http.get(`${BASE}/api/content`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json([]); + }) + ); + const user = userEvent.setup(); + render(Page); + const statusFilter = await screen.findByLabelText(/status filter/i); + await user.click(within(statusFilter).getByRole('button', { name: 'parked' })); + await waitFor(() => expect(capturedUrl).toContain('status=parked')); + }); + + test('all status filter does not send status param', async () => { + let capturedUrl = ''; + server.use( + http.get(`${BASE}/api/content`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json([]); + }) + ); + const user = userEvent.setup(); + render(Page); + const statusFilter = await screen.findByLabelText(/status filter/i); + await user.click(within(statusFilter).getByRole('button', { name: 'parked' })); + await waitFor(() => expect(capturedUrl).toContain('status=parked')); + await user.click(within(statusFilter).getByRole('button', { name: 'all' })); + await waitFor(() => expect(capturedUrl).not.toContain('status=')); + }); +}); diff --git a/apps/web/src/routes/review/+page.svelte b/apps/web/src/routes/review/+page.svelte index 28bf16d..732f723 100644 --- a/apps/web/src/routes/review/+page.svelte +++ b/apps/web/src/routes/review/+page.svelte @@ -1,15 +1,77 @@
@@ -24,13 +86,44 @@ onMount(async () => {

No in-flight items.

{:else}
    - {#each items as item} -
  • - {item.title} - PR #{item.pr} · {item.branch} - {#if item.id} - Open chat → - {/if} + {#each items as item (itemKey(item))} + {@const key = itemKey(item)} + {@const busy = acting.has(key)} +
  • +
    + {item.title} + PR #{item.pr} · {item.branch} +
    +
    + {#if item.id} + Open → + {#if item.type === 'idea'} + + {/if} + + + {/if} + +
  • {/each}
diff --git a/apps/web/src/routes/review/page.stories.svelte b/apps/web/src/routes/review/page.stories.svelte index 999cbed..83f3bdf 100644 --- a/apps/web/src/routes/review/page.stories.svelte +++ b/apps/web/src/routes/review/page.stories.svelte @@ -13,14 +13,55 @@ const { Story } = defineMeta({ HttpResponse.json([ - { pr: 12, branch: 'idea/focus-system', title: '[idea] Build a focus system' }, - { pr: 9, branch: 'plan/api-refactor', title: '[plan] Refactor API client' }, + { + pr: 12, + branch: 'idea/focus-system', + title: '[idea] Build a focus system', + id: '01JR4KFOCUS', + type: 'idea', + }, + ]) + ), + ], + }, + }} +/> + + + HttpResponse.json([ + { + pr: 9, + branch: 'plan/api-refactor', + title: '[plan] Refactor API client', + id: '01JR4KPLAN', + type: 'plan', + }, + ]) + ), + ], + }, + }} +/> + + + HttpResponse.json([ + { pr: 15, branch: 'idea/unmatched', title: '[idea] Unmatched draft' }, ]) ), ], @@ -29,7 +70,7 @@ const { Story } = defineMeta({ /> ({ goto: vi.fn() })); + +const BASE = 'http://localhost:8744'; + +const ideaItem = { pr: 7, branch: 'idea/abc', title: 'My idea', id: 'idea-xyz', type: 'idea' }; +const planItem = { pr: 8, branch: 'plan/def', title: 'My plan', id: 'plan-xyz', type: 'plan' }; +const noIdItem = { pr: 9, branch: 'idea/unmatched', title: 'Unmatched draft' }; + describe('/review', () => { test('shows empty state when no in-flight items', async () => { render(Page); @@ -11,33 +20,106 @@ describe('/review', () => { }); test('shows PR info for each in-flight item', async () => { + server.use(http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([ideaItem]))); + render(Page); + await waitFor(() => expect(screen.getByText('My idea')).toBeInTheDocument()); + expect(screen.getByText(/PR #7/)).toBeInTheDocument(); + }); + + test('shows Open link and Commit/Park/Discard for item with id', async () => { + server.use(http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([ideaItem]))); + render(Page); + await waitFor(() => expect(screen.getByRole('link', { name: /open/i })).toBeInTheDocument()); + expect(screen.getByRole('button', { name: /commit/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /park/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /discard/i })).toBeInTheDocument(); + }); + + test('shows Promote button for ideas but not plans', async () => { server.use( - http.get('http://localhost:8744/api/content/inflight', () => - HttpResponse.json([{ pr: 7, branch: 'idea/abc', title: 'My draft idea' }]) - ) + http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([ideaItem, planItem])) ); + render(Page); + await waitFor(() => expect(screen.getAllByRole('button', { name: /commit/i })).toHaveLength(2)); + const promoteButtons = screen.getAllByRole('button', { name: /promote/i }); + expect(promoteButtons).toHaveLength(1); + }); + test('shows only Discard for item without id', async () => { + server.use(http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([noIdItem]))); render(Page); + await waitFor(() => + expect(screen.getByRole('button', { name: /discard/i })).toBeInTheDocument() + ); + expect(screen.queryByRole('link', { name: /open/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /commit/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /park/i })).toBeNull(); + }); - await waitFor(() => expect(screen.getByText('My draft idea')).toBeInTheDocument()); - expect(screen.getByText(/PR #7/)).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /open chat/i })).toBeNull(); + test('Park button calls park endpoint and removes item', async () => { + server.use(http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([ideaItem]))); + const user = userEvent.setup(); + render(Page); + await waitFor(() => expect(screen.getByRole('button', { name: /park/i })).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: /park/i })); + await waitFor(() => expect(screen.queryByText('My idea')).toBeNull()); }); - test('shows Open chat link when item has an id', async () => { - server.use( - http.get('http://localhost:8744/api/content/inflight', () => - HttpResponse.json([{ pr: 7, branch: 'idea/abc', title: 'Linked idea', id: 'idea-xyz' }]) - ) + test('Commit button confirms then calls commit endpoint and removes item', async () => { + server.use(http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([ideaItem]))); + vi.spyOn(window, 'confirm').mockReturnValue(true); + const user = userEvent.setup(); + render(Page); + await waitFor(() => + expect(screen.getByRole('button', { name: /commit/i })).toBeInTheDocument() ); + await user.click(screen.getByRole('button', { name: /commit/i })); + await waitFor(() => expect(screen.queryByText('My idea')).toBeNull()); + }); + test('Discard button confirms then calls dismiss and removes item', async () => { + server.use(http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([ideaItem]))); + vi.spyOn(window, 'confirm').mockReturnValue(true); + const user = userEvent.setup(); render(Page); + await waitFor(() => + expect(screen.getByRole('button', { name: /discard/i })).toBeInTheDocument() + ); + await user.click(screen.getByRole('button', { name: /discard/i })); + await waitFor(() => expect(screen.queryByText('My idea')).toBeNull()); + }); + test('Promote calls contentById then capture and navigates to new plan', async () => { + const { goto: mockGoto } = await import('$app/navigation'); + const captureCalled = vi.fn(); + server.use( + http.get(`${BASE}/api/content/inflight`, () => HttpResponse.json([ideaItem])), + http.get(`${BASE}/api/content/idea-xyz`, () => + HttpResponse.json({ + id: 'idea-xyz', + type: 'idea', + title: 'My idea', + status: 'draft', + tags: [], + summary: 'A great idea', + created: '', + updated: '', + path: 'ideas/my-idea.md', + body: '', + }) + ), + http.post(`${BASE}/api/capture`, () => { + captureCalled(); + return HttpResponse.json({ id: 'new-plan-id', pr: 10, branch: 'plan/new-plan-id' }); + }) + ); + const user = userEvent.setup(); + render(Page); await waitFor(() => - expect(screen.getByRole('link', { name: /open chat/i })).toHaveAttribute( - 'href', - '/chat/idea-xyz' - ) + expect(screen.getByRole('button', { name: /promote/i })).toBeInTheDocument() ); + await user.click(screen.getByRole('button', { name: /promote/i })); + await waitFor(() => expect(captureCalled).toHaveBeenCalled()); + expect(mockGoto).toHaveBeenCalledWith('/chat/new-plan-id'); }); }); diff --git a/apps/web/src/tests/handlers.ts b/apps/web/src/tests/handlers.ts index 67f1e34..5d8c9f4 100644 --- a/apps/web/src/tests/handlers.ts +++ b/apps/web/src/tests/handlers.ts @@ -31,6 +31,7 @@ export const defaultHandlers = [ ), http.patch(`${BASE}/api/content/:id`, () => HttpResponse.json({ ok: true })), http.post(`${BASE}/api/content/:id/commit`, () => HttpResponse.json({ ok: true })), + http.post(`${BASE}/api/content/:id/park`, () => HttpResponse.json({ ok: true })), http.post(`${BASE}/api/content/:id/dismiss`, () => HttpResponse.json({ ok: true })), http.post(`${BASE}/api/chat`, () => HttpResponse.json({ reply: 'Hello', context: [] })), ];