superpowers
This commit is contained in:
263
.superpowers/sdd/task-3-brief.md
Normal file
263
.superpowers/sdd/task-3-brief.md
Normal file
@@ -0,0 +1,263 @@
|
||||
## Task 3: Workbench Shell, Project Rail, Channel Sidebar, And Topbar
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/src/features/workbench/ProjectWorkbench.svelte`
|
||||
- Create: `apps/web/src/features/workbench/ProjectRail.svelte`
|
||||
- Create: `apps/web/src/features/workbench/ProjectChannelSidebar.svelte`
|
||||
- Create: `apps/web/src/features/workbench/WorkspaceTopbar.svelte`
|
||||
- Modify: `apps/web/src/app/App.svelte`
|
||||
- Test: `apps/web/src/features/workbench/ProjectWorkbench.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `workbenchProjects: WorkbenchProject[]`
|
||||
- Consumes: `getProjectWorkspace(projectID: number): ProjectWorkspace`
|
||||
- Produces: `ProjectWorkbench` prop `currentUser: { account: string }`
|
||||
- Produces: selected project and selected channel rendered with accessible names.
|
||||
|
||||
- [ ] **Step 1: Write failing workbench shell test**
|
||||
|
||||
Create `apps/web/src/features/workbench/ProjectWorkbench.test.ts`:
|
||||
|
||||
```ts
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen } 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();
|
||||
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Global search')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Set-Location D:\work\senlinai\agent\apps\web
|
||||
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
|
||||
```
|
||||
|
||||
Expected: FAIL because workbench components do not exist.
|
||||
|
||||
- [ ] **Step 3: Create shell components**
|
||||
|
||||
Implement `ProjectRail.svelte`, `ProjectChannelSidebar.svelte`, and `WorkspaceTopbar.svelte` with explicit props and accessible buttons:
|
||||
|
||||
```svelte
|
||||
<!-- apps/web/src/features/workbench/ProjectRail.svelte -->
|
||||
<script lang="ts">
|
||||
import type { WorkbenchProject } from './types';
|
||||
export let projects: WorkbenchProject[];
|
||||
export let selectedProjectID: number;
|
||||
export let onSelectProject: (projectID: number) => void;
|
||||
</script>
|
||||
|
||||
<nav class="project-rail" aria-label="Project list">
|
||||
<button class="rail-logo" aria-label="Dashboard">SA</button>
|
||||
{#each projects as project}
|
||||
<button
|
||||
class:active={project.id === selectedProjectID}
|
||||
aria-pressed={project.id === selectedProjectID}
|
||||
aria-label={project.name}
|
||||
title={project.name}
|
||||
on:click={() => onSelectProject(project.id)}
|
||||
>
|
||||
<span>{project.initials}</span>
|
||||
{#if project.unreadCount}
|
||||
<small>{project.unreadCount}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="add-project" aria-label="Create project">+</button>
|
||||
</nav>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- apps/web/src/features/workbench/ProjectChannelSidebar.svelte -->
|
||||
<script lang="ts">
|
||||
import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
|
||||
export let project: WorkbenchProject;
|
||||
export let channels: WorkbenchChannel[];
|
||||
export let selectedChannelID: string;
|
||||
export let tags: string[];
|
||||
export let recentSessions: AISessionItem[];
|
||||
export let onSelectChannel: (channelID: string) => void;
|
||||
</script>
|
||||
|
||||
<aside class="channel-sidebar" aria-label="Project channels">
|
||||
<header>
|
||||
<div>
|
||||
<p>Current project</p>
|
||||
<h2>{project.name}</h2>
|
||||
</div>
|
||||
<button aria-label="Project settings">鈿?/button>
|
||||
</header>
|
||||
|
||||
<section class="tag-row" aria-label="Project tags">
|
||||
{#each tags as tag}
|
||||
<button type="button">#{tag}</button>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="channel-group">
|
||||
{#each channels as channel}
|
||||
<button
|
||||
class:active={channel.id === selectedChannelID}
|
||||
aria-pressed={channel.id === selectedChannelID}
|
||||
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
|
||||
on:click={() => onSelectChannel(channel.id)}
|
||||
>
|
||||
<span>{channel.title}</span>
|
||||
{#if channel.count !== undefined}
|
||||
<small>{channel.count}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="recent-sessions" aria-label="Recent sessions">
|
||||
<h3>Recent sessions</h3>
|
||||
{#each recentSessions as session}
|
||||
<button type="button">
|
||||
<span>{session.title}</span>
|
||||
<small>{session.updatedAt}</small>
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
</aside>
|
||||
```
|
||||
|
||||
```svelte
|
||||
<!-- apps/web/src/features/workbench/WorkspaceTopbar.svelte -->
|
||||
<script lang="ts">
|
||||
export let account: string;
|
||||
</script>
|
||||
|
||||
<header class="workspace-topbar">
|
||||
<div class="brand-mark">SenlinAI</div>
|
||||
<label class="search-box">
|
||||
<span>Search</span>
|
||||
<input aria-label="Global search" placeholder="Search projects, tasks, notes, AI sessions" />
|
||||
</label>
|
||||
<div class="topbar-actions">
|
||||
<button aria-label="Back">鈫?/button>
|
||||
<button aria-label="Forward">鈫?/button>
|
||||
<button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
|
||||
</div>
|
||||
</header>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `ProjectWorkbench.svelte` and wire into `App.svelte`**
|
||||
|
||||
Create `ProjectWorkbench.svelte`:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||||
import ProjectRail from './ProjectRail.svelte';
|
||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
||||
|
||||
export let currentUser: { account: string };
|
||||
|
||||
let selectedProjectID = workbenchProjects[0].id;
|
||||
$: workspace = getProjectWorkspace(selectedProjectID);
|
||||
$: selectedChannelID = workspace.channels[0].id;
|
||||
|
||||
function selectProject(projectID: number) {
|
||||
selectedProjectID = projectID;
|
||||
}
|
||||
|
||||
function selectChannel(channelID: string) {
|
||||
selectedChannelID = channelID;
|
||||
}
|
||||
</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>
|
||||
</section>
|
||||
<aside class="object-inspector" aria-label="Object inspector">
|
||||
<p>Select an item to inspect discussion, properties, and actions.</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
Modify `App.svelte` to import and render `ProjectWorkbench` when logged in:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
|
||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
||||
|
||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
||||
let currentUser: { account: string } | null = null;
|
||||
|
||||
function handleLogin(detail: { apiBase: string; account: string }) {
|
||||
apiBase = detail.apiBase;
|
||||
localStorage.setItem('apiBase', apiBase);
|
||||
currentUser = { account: detail.account };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if currentUser}
|
||||
<ProjectWorkbench {currentUser} />
|
||||
{:else}
|
||||
<ServerLogin {apiBase} onLogin={handleLogin} />
|
||||
{/if}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run test**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Set-Location D:\work\senlinai\agent\apps\web
|
||||
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Set-Location D:\work\senlinai\agent
|
||||
git add apps\web\src\app\App.svelte apps\web\src\features\workbench
|
||||
git commit -m "feat: add project channel workbench shell"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user