# Review package Task 4 v2 ## Commits 1a82a8b fix: address workbench inspector review findings 3194cbe feat: add workbench channel templates ## Stat .superpowers/sdd/task-4-report.md | 67 ++++++++++++++++++++++ .../src/features/workbench/ChannelContent.svelte | 30 ++++++++++ .../src/features/workbench/ChannelContent.test.ts | 39 +++++++++++++ .../src/features/workbench/ObjectInspector.svelte | 37 ++++++++++++ .../src/features/workbench/ProjectWorkbench.svelte | 15 +++-- .../features/workbench/ProjectWorkbench.test.ts | 22 ++++++- .../workbench/channels/AISessionsChannel.svelte | 27 +++++++++ .../features/workbench/channels/CronChannel.svelte | 19 ++++++ .../workbench/channels/CustomLinkChannel.svelte | 16 ++++++ .../workbench/channels/InboxChannel.svelte | 22 +++++++ .../workbench/channels/NotesSourcesChannel.svelte | 19 ++++++ .../workbench/channels/OverviewChannel.svelte | 29 ++++++++++ .../workbench/channels/TasksChannel.svelte | 23 ++++++++ 13 files changed, 360 insertions(+), 5 deletions(-) ## Diff diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 0000000..b743d8d --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,67 @@ +# Task 4 Report: Channel Content Templates And Object Inspector + +## Status + +Implemented and committed the Task 4 workbench channel templates and object inspector. + +## What Changed + +- Added `ChannelContent.svelte`, which dispatches `ProjectWorkspace` data to the selected system or custom channel template. +- Added seven channel templates for overview, inbox, tasks, AI sessions, notes and sources, cron plans, and external custom links. +- Made all applicable records selectable through an `Inspect ` action that supplies an `InspectorItem` to the workbench. +- Added `ObjectInspector.svelte` with Discussion, Properties, and More tabs, including empty-state behavior and object properties. +- Wired the selected channel and selected inspector item into `ProjectWorkbench.svelte`. +- Added the required channel dispatcher test, including the task-to-inspector callback assertion. +- Kept visual-system CSS untouched for Task 5. + +## TDD Evidence + +1. Added `apps/web/src/features/workbench/ChannelContent.test.ts` before creating any production channel component. +2. Ran `npm test -- --run src/features/workbench/ChannelContent.test.ts` from `apps/web`. +3. Observed the expected red failure: Vite could not resolve `./ChannelContent.svelte` because it did not exist. +4. Implemented the dispatcher and channel components, then ran the focused channel and workbench tests. +5. Corrected the inspector tab semantics after the green run exposed Svelte accessibility warnings, then reran verification with no warnings. + +## Tests + +- `npm test -- --run src/features/workbench/ChannelContent.test.ts src/features/workbench/ProjectWorkbench.test.ts` + - Passed: 2 test files, 4 tests. +- `npm run build` + - Passed: Vite production build completed successfully. +- `git diff --check` + - Passed: no whitespace errors. + +## Files Changed + +- Created `apps/web/src/features/workbench/ChannelContent.svelte` +- Created `apps/web/src/features/workbench/ChannelContent.test.ts` +- Created `apps/web/src/features/workbench/ObjectInspector.svelte` +- Created `apps/web/src/features/workbench/channels/OverviewChannel.svelte` +- Created `apps/web/src/features/workbench/channels/InboxChannel.svelte` +- Created `apps/web/src/features/workbench/channels/TasksChannel.svelte` +- Created `apps/web/src/features/workbench/channels/AISessionsChannel.svelte` +- Created `apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte` +- Created `apps/web/src/features/workbench/channels/CronChannel.svelte` +- Created `apps/web/src/features/workbench/channels/CustomLinkChannel.svelte` +- Modified `apps/web/src/features/workbench/ProjectWorkbench.svelte` +- Created this report: `.superpowers/sdd/task-4-report.md` + +## Self-Review + +- Confirmed every `WorkbenchChannel` type has a dedicated rendered template through the dispatcher. +- Confirmed the custom link uses the required `Open external channel` text and provides a copy-style action. +- Confirmed task inspection produces the exact task title required by the test. +- Confirmed the inspector remains visible through its existing `aria-label` and exposes semantic tab state using `role="tab"` with `aria-selected`. +- Confirmed no React, shadcn/ui dependency, or index.css modification was introduced. +- Confirmed unrelated existing `.superpowers/sdd` files were not staged. + +## Concerns + +- The copy URL and inspector More-tab actions are present as MVP UI controls; beyond writing the custom URL to the clipboard, they intentionally do not persist or mutate workbench data. +- Visual styling is limited to semantic class hooks by design; Task 5 owns the visual-system CSS work. + +## Review Fixes + +- Replaced the incomplete ARIA tabs pattern in `ObjectInspector.svelte` with ordinary pressed view-switcher buttons, removing tab and tablist roles that required a full tab-panel keyboard interaction model. +- Extended `ProjectWorkbench.test.ts` to select the Work Plan channel, inspect a task, and assert the rendered inspector title plus Properties values within the object inspector. +- Focused workbench coverage passed after the changes: 2 test files, 5 tests. diff --git a/apps/web/src/features/workbench/ChannelContent.svelte b/apps/web/src/features/workbench/ChannelContent.svelte new file mode 100644 index 0000000..9e81962 --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import type { InspectorItem, ProjectWorkspace, WorkbenchChannel } from './types'; + import OverviewChannel from './channels/OverviewChannel.svelte'; + import InboxChannel from './channels/InboxChannel.svelte'; + import TasksChannel from './channels/TasksChannel.svelte'; + import AISessionsChannel from './channels/AISessionsChannel.svelte'; + import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte'; + import CronChannel from './channels/CronChannel.svelte'; + import CustomLinkChannel from './channels/CustomLinkChannel.svelte'; + + export let workspace: ProjectWorkspace; + export let channel: WorkbenchChannel; + export let onInspect: (item: InspectorItem) => void; +</script> + +{#if channel.type === 'overview'} + <OverviewChannel {workspace} {onInspect} /> +{:else if channel.type === 'inbox'} + <InboxChannel messages={workspace.inbox} {onInspect} /> +{:else if channel.type === 'tasks'} + <TasksChannel tasks={workspace.tasks} {onInspect} /> +{:else if channel.type === 'ai_sessions'} + <AISessionsChannel sessions={workspace.aiSessions} {onInspect} /> +{:else if channel.type === 'notes_sources'} + <NotesSourcesChannel items={workspace.notesSources} {onInspect} /> +{:else if channel.type === 'cron'} + <CronChannel plans={workspace.cronPlans} {onInspect} /> +{:else} + <CustomLinkChannel {channel} /> +{/if} diff --git a/apps/web/src/features/workbench/ChannelContent.test.ts b/apps/web/src/features/workbench/ChannelContent.test.ts new file mode 100644 index 0000000..41c4368 --- /dev/null +++ b/apps/web/src/features/workbench/ChannelContent.test.ts @@ -0,0 +1,39 @@ +import '@testing-library/jest-dom/vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { describe, expect, it } from 'vitest'; +import ChannelContent from './ChannelContent.svelte'; +import { getProjectWorkspace } from './mockData'; + +describe('ChannelContent', () => { + const workspace = getProjectWorkspace(1); + + it('renders different templates for system and custom channels', () => { + for (const type of ['overview', 'inbox', 'tasks', 'ai_sessions', 'notes_sources', 'cron', 'custom_link'] as const) { + const channel = workspace.channels.find((item) => item.type === type); + if (!channel) throw new Error(`missing ${type}`); + + render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } }); + } + + expect(screen.getByText('Open external channel')).toBeInTheDocument(); + }); + + it('sends selected task details to inspector', async () => { + let inspectedTitle = ''; + const channel = workspace.channels.find((item) => item.type === 'tasks'); + if (!channel) throw new Error('missing task channel'); + + render(ChannelContent, { + props: { + workspace, + channel, + onInspect: (item) => { + inspectedTitle = item.title; + }, + }, + }); + + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + expect(inspectedTitle).toBe('Confirm homepage information architecture'); + }); +}); diff --git a/apps/web/src/features/workbench/ObjectInspector.svelte b/apps/web/src/features/workbench/ObjectInspector.svelte new file mode 100644 index 0000000..79fa781 --- /dev/null +++ b/apps/web/src/features/workbench/ObjectInspector.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { InspectorItem } from './types'; + + export let item: InspectorItem | null; + let activeTab: 'discussion' | 'properties' | 'more' = 'discussion'; +</script> + +<aside class="object-inspector" aria-label="Object inspector"> + <div class="inspector-tabs"> + <button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button> + <button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button> + <button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button> + </div> + + {#if item} + <h2>{item.title}</h2> + <p>{item.description}</p> + {#if activeTab === 'discussion'} + <p>No comments yet. Add project discussion here later.</p> + {:else if activeTab === 'properties'} + <dl> + {#each item.properties as property} + <div> + <dt>{property.label}</dt> + <dd>{property.value}</dd> + </div> + {/each} + </dl> + {:else} + <button type="button">Copy link</button> + <button type="button">Archive</button> + {/if} + {:else} + <h2>Inspector</h2> + <p>Select an item to inspect discussion, properties, and actions.</p> + {/if} +</aside> diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte index 4ab9ffd..3d29c30 100644 --- a/apps/web/src/features/workbench/ProjectWorkbench.svelte +++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte @@ -1,41 +1,48 @@ <script lang="ts"> import { getProjectWorkspace, workbenchProjects } from './mockData'; + import ChannelContent from './ChannelContent.svelte'; + import ObjectInspector from './ObjectInspector.svelte'; import ProjectRail from './ProjectRail.svelte'; import ProjectChannelSidebar from './ProjectChannelSidebar.svelte'; import WorkspaceTopbar from './WorkspaceTopbar.svelte'; + import type { InspectorItem } from './types'; export let currentUser: { account: string }; let selectedProjectID = workbenchProjects[0].id; + let selectedItem: InspectorItem | null = null; $: workspace = getProjectWorkspace(selectedProjectID); $: selectedChannelID = workspace.channels[0].id; + $: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0]; function selectProject(projectID: number) { selectedProjectID = projectID; } function selectChannel(channelID: string) { selectedChannelID = channelID; } + + function inspectItem(item: InspectorItem) { + selectedItem = item; + } </script> <main class="workbench-shell"> <WorkspaceTopbar account={currentUser.account} /> <div class="workbench-body"> <ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} /> <ProjectChannelSidebar project={workspace.project} channels={workspace.channels} {selectedChannelID} tags={workspace.tags} recentSessions={workspace.recentSessions} onSelectChannel={selectChannel} /> <section class="channel-stage" aria-label="Channel content"> - <h1>{workspace.channels.find((channel) => channel.id === selectedChannelID)?.title}</h1> + <ChannelContent {workspace} channel={selectedChannel} onInspect={inspectItem} /> </section> - <aside class="object-inspector" aria-label="Object inspector"> - <p>Select an item to inspect discussion, properties, and actions.</p> - </aside> + <ObjectInspector item={selectedItem} /> </div> </main> diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts index cf251b3..61e15b1 100644 --- a/apps/web/src/features/workbench/ProjectWorkbench.test.ts +++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts @@ -1,12 +1,12 @@ import '@testing-library/jest-dom/vitest'; -import { fireEvent, render, screen } from '@testing-library/svelte'; +import { fireEvent, render, screen, within } from '@testing-library/svelte'; import { describe, expect, it } from 'vitest'; import ProjectWorkbench from './ProjectWorkbench.svelte'; describe('ProjectWorkbench', () => { it('renders project rail, channel sidebar, topbar, and inspector', () => { render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); expect(screen.getByLabelText('Project list')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByLabelText('Project channels')).toBeInTheDocument(); @@ -16,11 +16,31 @@ describe('ProjectWorkbench', () => { }); it('refreshes channels when switching projects', async () => { render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); await fireEvent.click(screen.getByRole('button', { name: 'Project A2' })); expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByText('Ops Dashboard')).toBeInTheDocument(); }); + + it('shows inspected task details in the inspector', async () => { + render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } }); + const inspector = screen.getByLabelText('Object inspector'); + + expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true'); + + await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' })); + await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })); + + expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument(); + + await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' })); + + expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(inspector).getByText('Status')).toBeInTheDocument(); + expect(within(inspector).getByText('Open')).toBeInTheDocument(); + expect(within(inspector).getByText('Owner')).toBeInTheDocument(); + expect(within(inspector).getByText('David')).toBeInTheDocument(); + }); }); diff --git a/apps/web/src/features/workbench/channels/AISessionsChannel.svelte b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte new file mode 100644 index 0000000..3b52383 --- /dev/null +++ b/apps/web/src/features/workbench/channels/AISessionsChannel.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import type { AISessionItem, InspectorItem } from '../types'; + + export let sessions: AISessionItem[]; + export let onInspect: (item: InspectorItem) => void; + let selectedSessionID = sessions[0]?.id; + + $: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0]; + + function inspectSession(session: AISessionItem) { + onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] }); + } +</script> + +<section class="channel-page ai-sessions-channel"> + <header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header> + <div class="session-layout"> + <div class="session-list"> + {#each sessions as session} + <button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button> + {/each} + </div> + {#if selectedSession} + <article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article> + {/if} + </div> +</section> diff --git a/apps/web/src/features/workbench/channels/CronChannel.svelte b/apps/web/src/features/workbench/channels/CronChannel.svelte new file mode 100644 index 0000000..7a6f2c3 --- /dev/null +++ b/apps/web/src/features/workbench/channels/CronChannel.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import type { CronPlan, InspectorItem } from '../types'; + + export let plans: CronPlan[]; + export let onInspect: (item: InspectorItem) => void; + + function inspectPlan(plan: CronPlan) { + onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] }); + } +</script> + +<section class="channel-page cron-channel"> + <header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header> + <div class="cron-list"> + {#each plans as plan} + <article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article> + {/each} + </div> +</section> diff --git a/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte new file mode 100644 index 0000000..0fa9111 --- /dev/null +++ b/apps/web/src/features/workbench/channels/CustomLinkChannel.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + import type { WorkbenchChannel } from '../types'; + + export let channel: WorkbenchChannel; + + function copyUrl() { + void navigator.clipboard?.writeText(channel.url ?? ''); + } +</script> + +<section class="channel-page custom-link-channel"> + <header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header> + <p>{channel.url}</p> + <a href={channel.url} target="_blank" rel="noreferrer">Open external channel</a> + <button type="button" on:click={copyUrl}>Copy URL</button> +</section> diff --git a/apps/web/src/features/workbench/channels/InboxChannel.svelte b/apps/web/src/features/workbench/channels/InboxChannel.svelte new file mode 100644 index 0000000..2d25d63 --- /dev/null +++ b/apps/web/src/features/workbench/channels/InboxChannel.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import type { InboxMessage, InspectorItem } from '../types'; + + export let messages: InboxMessage[]; + export let onInspect: (item: InspectorItem) => void; + + function inspectMessage(message: InboxMessage) { + onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] }); + } +</script> + +<section class="channel-page inbox-channel"> + <header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header> + <div class="message-list"> + {#each messages as message} + <article class="message-row"> + <div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div> + <button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button> + </article> + {/each} + </div> +</section> diff --git a/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte new file mode 100644 index 0000000..256ef8b --- /dev/null +++ b/apps/web/src/features/workbench/channels/NotesSourcesChannel.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import type { InspectorItem, NoteSourceItem } from '../types'; + + export let items: NoteSourceItem[]; + export let onInspect: (item: InspectorItem) => void; + + function inspectItem(item: NoteSourceItem) { + onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] }); + } +</script> + +<section class="channel-page notes-sources-channel"> + <header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header> + <div class="file-list"> + {#each items as item} + <article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article> + {/each} + </div> +</section> diff --git a/apps/web/src/features/workbench/channels/OverviewChannel.svelte b/apps/web/src/features/workbench/channels/OverviewChannel.svelte new file mode 100644 index 0000000..6d478cf --- /dev/null +++ b/apps/web/src/features/workbench/channels/OverviewChannel.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import type { InspectorItem, ProjectWorkspace } from '../types'; + + export let workspace: ProjectWorkspace; + export let onInspect: (item: InspectorItem) => void; + + const metrics = (workspace: ProjectWorkspace) => [ + { label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' }, + { label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' }, + { label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' }, + { label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' }, + { label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' }, + ]; +</script> + +<section class="channel-page overview-channel"> + <header class="channel-header"> + <p>{workspace.project.name}</p> + <h1>Overview</h1> + </header> + <div class="metric-list"> + {#each metrics(workspace) as metric} + <button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}> + <strong>{metric.value}</strong> + <span>{metric.label}</span> + </button> + {/each} + </div> +</section> diff --git a/apps/web/src/features/workbench/channels/TasksChannel.svelte b/apps/web/src/features/workbench/channels/TasksChannel.svelte new file mode 100644 index 0000000..b59e47e --- /dev/null +++ b/apps/web/src/features/workbench/channels/TasksChannel.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import type { InspectorItem, WorkTask } from '../types'; + + export let tasks: WorkTask[]; + export let onInspect: (item: InspectorItem) => void; + + function inspectTask(task: WorkTask) { + onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] }); + } +</script> + +<section class="channel-page tasks-channel"> + <header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header> + <div class="task-list"> + {#each tasks as task} + <article class:completed={task.completed} class="task-card"> + <span class="task-check" aria-hidden="true">{task.completed ? 'Done' : 'Open'}</span> + <div><h2>{task.title}</h2><p>{task.summary}</p><small>{task.owner} · {task.due} · #{task.tag}</small></div> + <button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button> + </article> + {/each} + </div> +</section>