264 lines
8.3 KiB
Markdown
264 lines
8.3 KiB
Markdown
## 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"
|
|
```
|
|
|
|
---
|
|
|