8.3 KiB
8.3 KiB
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:
ProjectWorkbenchpropcurrentUser: { 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:
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:
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:
<!-- 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>
<!-- 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>
<!-- 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.svelteand wire intoApp.svelte
Create ProjectWorkbench.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:
<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:
Set-Location D:\work\senlinai\agent\apps\web
npm test -- --run src/features/workbench/ProjectWorkbench.test.ts
Expected: PASS.
- Step 6: Commit
Run:
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"