Files
agent/.superpowers/sdd/task-3-review-package.md
2026-07-20 08:02:20 +08:00

235 lines
8.6 KiB
Markdown

# Review package Task 3
## Commits
95dce81 feat: add project channel workbench shell
## Stat
apps/web/src/app/App.svelte | 6 +--
.../workbench/ProjectChannelSidebar.svelte | 52 ++++++++++++++++++++++
apps/web/src/features/workbench/ProjectRail.svelte | 26 +++++++++++
.../src/features/workbench/ProjectWorkbench.svelte | 41 +++++++++++++++++
.../features/workbench/ProjectWorkbench.test.ts | 26 +++++++++++
.../src/features/workbench/WorkspaceTopbar.svelte | 16 +++++++
6 files changed, 163 insertions(+), 4 deletions(-)
## Diff
diff --git a/apps/web/src/app/App.svelte b/apps/web/src/app/App.svelte
index a5d8dce..e7170ef 100644
--- a/apps/web/src/app/App.svelte
+++ b/apps/web/src/app/App.svelte
@@ -1,21 +1,19 @@
<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}
- <main class="workbench-placeholder" aria-label="Project workbench">
- <h1>SenlinAI Workbench</h1>
- <p>Signed in as {currentUser.account}</p>
- </main>
+ <ProjectWorkbench {currentUser} />
{:else}
<ServerLogin {apiBase} onLogin={handleLogin} />
{/if}
diff --git a/apps/web/src/features/workbench/ProjectChannelSidebar.svelte b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
new file mode 100644
index 0000000..40cb1a0
--- /dev/null
+++ b/apps/web/src/features/workbench/ProjectChannelSidebar.svelte
@@ -0,0 +1,52 @@
+<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">&#9881;</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>
diff --git a/apps/web/src/features/workbench/ProjectRail.svelte b/apps/web/src/features/workbench/ProjectRail.svelte
new file mode 100644
index 0000000..d1be341
--- /dev/null
+++ b/apps/web/src/features/workbench/ProjectRail.svelte
@@ -0,0 +1,26 @@
+<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>
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.svelte b/apps/web/src/features/workbench/ProjectWorkbench.svelte
new file mode 100644
index 0000000..4ab9ffd
--- /dev/null
+++ b/apps/web/src/features/workbench/ProjectWorkbench.svelte
@@ -0,0 +1,41 @@
+<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>
diff --git a/apps/web/src/features/workbench/ProjectWorkbench.test.ts b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
new file mode 100644
index 0000000..cf251b3
--- /dev/null
+++ b/apps/web/src/features/workbench/ProjectWorkbench.test.ts
@@ -0,0 +1,26 @@
+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();
+ });
+});
diff --git a/apps/web/src/features/workbench/WorkspaceTopbar.svelte b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
new file mode 100644
index 0000000..c828fcf
--- /dev/null
+++ b/apps/web/src/features/workbench/WorkspaceTopbar.svelte
@@ -0,0 +1,16 @@
+<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">&#8592;</button>
+ <button aria-label="Forward">&#8594;</button>
+ <button aria-label={`Account ${account}`}>{account.slice(0, 2).toUpperCase()}</button>
+ </div>
+</header>