From 1d0d7c5de1fdfdd708d67b982498569fa9a67b87 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Fri, 14 Aug 2026 20:07:13 +0700
Subject: [PATCH 01/11] docs(web): approve adaptive workspace shell
---
...4-adaptive-workspace-shell-agent-design.md | 166 ++++++++++++++++++
1 file changed, 166 insertions(+)
create mode 100644 docs/superpowers/specs/2026-08-14-adaptive-workspace-shell-agent-design.md
diff --git a/docs/superpowers/specs/2026-08-14-adaptive-workspace-shell-agent-design.md b/docs/superpowers/specs/2026-08-14-adaptive-workspace-shell-agent-design.md
new file mode 100644
index 00000000..e84441ef
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-14-adaptive-workspace-shell-agent-design.md
@@ -0,0 +1,166 @@
+# Adaptive Workspace Shell and Agent Design
+
+**Status:** Approved by the product owner on 2026-08-14
+**Applies to:** DataBreeze Web workspace shell, Dashboard, Analysis, Data, Inbox, Reviews, Settings, and the shared workspace agent
+**Refines:** `2026-08-12-unified-data-workspace-experience-design.md`, `404-dashboard-workspace-redesign-design.md`, and Web tasks in plan `406`
+**Requirement links:** WEB-002, WEB-013, WEB-014, WEB-019, WEB-020, WEB-021, WEB-022, WEB-024; DDA-015, DDA-016, DDA-020, DDA-021, DDA-022, DDA-024, DDA-026, DDA-043, DDA-055, DDA-056
+
+## 1. Product outcome
+
+DataBreeze uses one calm, premium, blue workspace shell. The shell makes the product hierarchy obvious without reducing the dashboard canvas or agent to small cards. The interface must feel intentionally designed, not like a collection of unrelated generated pages.
+
+The user can:
+
+1. Expand or collapse the global sidebar.
+2. Reach the three primary destinations immediately.
+3. Reach operational workspace tools without giving them equal visual weight.
+4. Use the Dashboard as a full canvas with no analysis-history column inside it.
+5. Open one shared agent from Dashboard or Data, switch conversations, continue history, ask a question, inspect chart proposals, select compatible charts, and explicitly add them to the dashboard canvas.
+
+## 2. Adaptive global sidebar
+
+### 2.1 Desktop expanded state
+
+- The sidebar is expanded by default on a new desktop browser profile.
+- It is 248 pixels wide and contains the DataBreeze wordmark at the top.
+- A visible collapse control sits beside or immediately below the wordmark.
+- Primary navigation is grouped under the workspace heading and contains exactly Dashboard, Analysis, and Data.
+- Secondary workspace tools contain Inbox, Reviews, and Settings. They are visually quieter than the primary destinations.
+- Each entry uses the existing approved icon set plus a Vietnamese or English label.
+- The bottom area may contain locale, account, or settings access only when those actions are not already available more clearly in the top bar.
+
+### 2.2 Desktop compact state
+
+- The sidebar is 72 pixels wide.
+- Only the DataBreeze brand mark, navigation icons, and expand control remain visible.
+- Every icon retains an accessible name and a native tooltip/title.
+- The current destination remains visually obvious.
+- The user's preference is remembered per browser device. No tenant, member, dataset, or source identity is persisted with this preference.
+
+### 2.3 Responsive state
+
+- Between 768 and 1023 pixels, compact mode is the default unless the user explicitly expands it.
+- Below 768 pixels, the sidebar opens as an expanded modal navigation drawer and closes on route selection, Escape, or explicit close.
+- Reduced-motion preference removes width and transform animation without removing state feedback.
+
+## 3. Information architecture
+
+The shell preserves exactly three primary destinations:
+
+1. **Dashboard** — governed visual canvas, filters, freshness, evidence, and agent.
+2. **Analysis** — complete AI conversation workspace with full conversation history.
+3. **Data** — datasets, sources, preparation, versions, and health.
+
+Secondary workspace tools are:
+
+- **Inbox** — governed incoming artifacts and review state.
+- **Reviews** — CSV/XLSX intake, preparation review, ETL evidence, and explicit acceptance.
+- **Settings** — members, agent grants, sessions, and workspace controls.
+
+Secondary tools stay reachable from the sidebar but do not become primary product destinations. Existing governed routes and authorization boundaries remain unchanged.
+
+## 4. Dashboard canvas
+
+- Dashboard renders directly on a pale blue canvas field.
+- The application sidebar is its only permanent left navigation.
+- The Dashboard must not render `Phân tích mới`, conversation search, or an analysis-history column.
+- The dashboard page, title, filters, freshness, autosave state, KPI widgets, charts, evidence affordances, and agent entry remain visible.
+- White widget surfaces use restrained blue-gray borders, moderate radii, quiet depth, and no decorative gradients.
+- The canvas uses available width in either sidebar state and reflows without horizontal clipping.
+- Moving, resizing, removing, restoring, and keyboard operations continue to use stable widget IDs and existing governed authoring commands.
+
+## 5. Shared workspace agent
+
+### 5.1 Compact entry point
+
+- Dashboard and Data show the shared agent button at the bottom-right.
+- Analysis does not render a second floating agent because Analysis is already the full agent interface.
+- The button uses the current DataBreeze brand asset and an accessible label.
+
+### 5.2 Agent panel structure
+
+Opening the agent displays a responsive AI chat panel:
+
+1. DataBreeze agent identity and current authorized context.
+2. Conversation switcher showing authorized conversations only.
+3. New conversation action inside the agent, not inside the Dashboard canvas.
+4. Scrollable message history with distinct user and assistant treatment.
+5. Context summary for selected dashboard, datasets, versions, filters, or widget target.
+6. Composer with send action, loading state, denial state, and retry-safe error state.
+7. Link to open the same conversation in the full Analysis destination.
+
+The panel is approximately 420 pixels wide on wide screens, becomes a right-side sheet on medium screens, and a full-height bottom sheet on narrow screens.
+
+### 5.3 Conversation behavior
+
+- The compact agent and Analysis consume the same authorized conversation summaries and active-conversation identity.
+- Switching a conversation restores its permitted dashboard/dataset/version context without exposing hidden resources.
+- Creating a conversation inherits clearly displayed current scope or asks for scope before sending.
+- Conversation continuation reauthorizes the current member and current resource versions.
+- The UI never fabricates prior messages, source values, or results when the conversation API is unavailable.
+
+## 6. Dashboard chart proposal journey
+
+When the user asks to show something on the Dashboard:
+
+1. The agent records the question in the active authorized conversation.
+2. The system resolves a typed analysis plan against current permissions and exact dataset versions.
+3. The agent explains the proposed analytical framing in concise language.
+4. The panel displays only compatible allowlisted chart proposal cards.
+5. Each card shows chart type, title, rationale, supported dimensions/metric, important assumptions, evidence behavior, and a bounded preview state.
+6. The user selects one or more cards.
+7. The primary confirmation names the exact consequence, for example `Thêm 2 biểu đồ vào canvas`.
+8. Only that confirmation may create the next immutable dashboard version and place widgets on the canvas.
+9. The canvas communicates saving, saved, failed, or conflict state and focuses the first inserted widget when successful.
+
+The agent never generates executable UI, arbitrary chart code, SQL, JavaScript, or authoritative numeric values. It never publishes, broadens audience, changes permissions, or silently alters the shared dashboard.
+
+## 7. Page organization and visual system
+
+All first-party Web pages use:
+
+- Be Vietnam Pro.
+- Cobalt `#075DE8` for primary actions and selection.
+- Deep blue `#102A63` for primary text and selected navigation.
+- Pale blue-gray `#F4F7FC` or equivalent canvas background.
+- White working surfaces with blue-gray borders.
+- Moderate 10–18 pixel radii based on hierarchy.
+- Shadows with no more than 8 pixels of blur when paired with borders.
+- No decorative gradients, oversized marketing headings, emoji icons, handcrafted placeholder art, or excessive pill containers.
+
+Each page follows the same information order:
+
+1. Compact breadcrumb or eyebrow when needed.
+2. One clear page title and explanatory sentence.
+3. Current state or critical action.
+4. Primary work surface.
+5. Secondary details and evidence.
+
+Loading, empty, unavailable, unauthorized, conflict, and success are visibly distinct. An unavailable backend never appears as confirmed-empty data.
+
+## 8. Accessibility and interaction
+
+- Sidebar collapse/expand is a real button with `aria-expanded` and an explicit label.
+- Navigation remains reachable and understandable at 200 percent zoom.
+- Agent conversation switching, proposal selection, confirmation, and close are keyboard operable.
+- Focus moves into the agent on open, returns to the trigger on close, and moves to an inserted widget after confirmed placement.
+- Icon-only navigation keeps accessible text and tooltip titles.
+- Forced-colors and reduced-motion states remain functional.
+- Vietnamese is complete and default; English remains complete.
+
+## 9. Verification
+
+Implementation is not complete until the following pass:
+
+- Unit tests for sidebar default, collapse, remembered preference, icon-only labels, mobile drawer, and route selection.
+- Shell tests proving exactly three primary destinations and the quieter secondary tool group.
+- Dashboard tests proving no analysis-history or `Phân tích mới` appears in the canvas.
+- Agent tests for conversation switching, new conversation, contextual history, message composition, Analysis deep link, proposal selection, explicit confirmation, and no silent mutation.
+- Existing dashboard authoring, tenant-isolation, generated-contract, and permission tests.
+- Web TypeScript check, full Web tests, production build, bundle budget, and local container health.
+- Signed-in browser comparison against the approved dashboard reference at desktop and narrow widths.
+
+## 10. Normative delta
+
+This design replaces the earlier presentation rule that the Dashboard permanently hosts a retractable analysis-history panel. Conversation history now lives in the full Analysis destination and inside the opened compact agent. The global application rail becomes an adaptive sidebar that can display labels. The requirement for exactly three primary destinations, one shared agent, explicit chart confirmation, governed typed plans, and a pale-blue dashboard canvas remains unchanged.
+
From 5f6fe8a72492802b0407fc3c734493a2530c5f2c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Fri, 14 Aug 2026 20:12:43 +0700
Subject: [PATCH 02/11] docs(web): approve adaptive workspace implementation
---
...ve-workspace-shell-agent-implementation.md | 502 ++++++++++++++++++
1 file changed, 502 insertions(+)
create mode 100644 docs/plans/409-adaptive-workspace-shell-agent-implementation.md
diff --git a/docs/plans/409-adaptive-workspace-shell-agent-implementation.md b/docs/plans/409-adaptive-workspace-shell-agent-implementation.md
new file mode 100644
index 00000000..7f515c59
--- /dev/null
+++ b/docs/plans/409-adaptive-workspace-shell-agent-implementation.md
@@ -0,0 +1,502 @@
+# Adaptive Workspace Shell and Agent Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Status:** Approved by the product owner on 2026-08-14
+
+**Goal:** Deliver the approved premium blue Web workspace with an adaptive sidebar, a dashboard-only canvas, and one conversation-aware agent that can propose and explicitly add compatible charts.
+
+**Architecture:** Keep the existing React Router shell, generated conversation contracts, governed dashboard authoring commands, and server authorization boundaries. Add a presentation-only sidebar preference, derive authorized secondary navigation from the existing registry, synchronize conversation summaries through the shared agent store, and reuse the existing dashboard proposal/confirmation pipeline inside a richer chat surface.
+
+**Tech Stack:** React 19, React Router 7, TypeScript 5.9, TanStack Query 5, Vitest, Testing Library, Vite, existing DataBreeze design tokens and generated contracts.
+
+## Global Constraints
+
+- Requirements: WEB-002, WEB-013, WEB-014, WEB-019, WEB-020, WEB-021, WEB-022, WEB-024; DDA-015, DDA-016, DDA-020, DDA-021, DDA-022, DDA-024, DDA-026, DDA-043, DDA-055, DDA-056.
+- Vietnamese remains the default locale and English remains complete.
+- Cobalt `#075DE8`, deep blue `#102A63`, and pale blue-gray `#F4F7FC` are the primary workspace colors.
+- Do not add decorative gradients, emoji icons, arbitrary generated UI, SQL, JavaScript, or authoritative numeric values.
+- The client never treats navigation filtering, stored preferences, or conversation presentation state as authorization.
+- Dashboard mutations continue through existing governed authoring commands and require explicit user confirmation.
+- Sidebar persistence stores only the compact/expanded presentation preference under `databreeze.sidebar.compact.v1`.
+- Preserve existing tenant isolation, generated-contract parsing, exact version context, audit, evidence, and fail-closed unavailable states.
+
+---
+
+## File Structure
+
+- `apps/web/src/components/sidebar-preference.ts` — bounded browser-only compact preference.
+- `apps/web/src/components/application-rail.tsx` — adaptive sidebar presentation and accessible controls.
+- `apps/web/src/components/shell-layout.tsx` — shell state, authorized secondary tools, and direct dashboard canvas outlet.
+- `apps/web/src/styles/workspace-shell.css` — expanded/compact/mobile shell geometry and common page rhythm.
+- `apps/web/src/features/agent/agent-store.ts` — shared authorized conversation summaries and active identity.
+- `apps/web/src/features/agent/agent-chat-shell.tsx` — reusable conversation switcher, message list, context, and composer.
+- `apps/web/src/features/agent/floating-agent-panel.tsx` — Data route composition around the shared chat shell.
+- `apps/web/src/features/analysis/analysis-route-page.tsx` — authoritative conversation synchronization.
+- `apps/web/src/features/dashboards/dashboard-agent-panel.tsx` — dashboard chat plus chart proposal selection.
+- `apps/web/src/features/dashboards/dashboard-page.tsx` — chat presentation state and governed chart insertion.
+- Existing route CSS files — consistent blue canvas, page hierarchy, focus, responsive, and forced-color behavior.
+
+---
+
+### Task 1: Adaptive sidebar preference and accessible navigation
+
+**Files:**
+- Create: `apps/web/src/components/sidebar-preference.ts`
+- Modify: `apps/web/src/components/application-rail.tsx`
+- Modify: `apps/web/src/styles/workspace-shell.css`
+- Test: `apps/web/test/application-rail.test.tsx`
+- Test: `apps/web/test/navigation-access.test.tsx`
+
+**Interfaces:**
+- Produces: `readSidebarCompactPreference(): boolean | undefined`
+- Produces: `writeSidebarCompactPreference(compact: boolean): void`
+- Produces: `ApplicationRailProperties.collapsed`, `onCollapsedChange`, and `secondaryItems`
+- Consumes: `UdwPrimaryNavItemV1`, `NavigationItem`, and existing route labels.
+
+- [ ] **Step 1: Write failing sidebar tests**
+
+```tsx
+it('starts expanded, collapses to icon-only navigation, and remembers the preference', async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+ expect(screen.getByText('Bảng điều khiển')).toBeVisible();
+ await user.click(screen.getByRole('button', { name: 'Thu gọn thanh bên' }));
+ expect(screen.getByRole('navigation', { name: 'Điều hướng chính' })).toHaveAttribute(
+ 'data-collapsed',
+ 'true',
+ );
+ expect(localStorage.getItem('databreeze.sidebar.compact.v1')).toBe('true');
+ expect(screen.getByRole('link', { name: 'Bảng điều khiển' })).toHaveAttribute(
+ 'title',
+ 'Bảng điều khiển',
+ );
+});
+
+it('renders authorized Inbox, Reviews, and Settings as secondary tools', async () => {
+ renderDashboard();
+ expect(await screen.findByRole('link', { name: 'Hộp thư đến' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Cần xem xét' })).toBeVisible();
+ expect(screen.getByRole('link', { name: 'Cài đặt' })).toBeVisible();
+});
+```
+
+- [ ] **Step 2: Run the sidebar tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/navigation-access.test.tsx`
+
+Expected: FAIL because no collapse control, preference helper, or secondary group exists.
+
+- [ ] **Step 3: Implement the bounded preference helper**
+
+```ts
+const SIDEBAR_COMPACT_KEY = 'databreeze.sidebar.compact.v1';
+
+export function readSidebarCompactPreference(): boolean | undefined {
+ const value = globalThis.localStorage?.getItem(SIDEBAR_COMPACT_KEY);
+ return value === 'true' ? true : value === 'false' ? false : undefined;
+}
+
+export function writeSidebarCompactPreference(compact: boolean): void {
+ globalThis.localStorage?.setItem(SIDEBAR_COMPACT_KEY, String(compact));
+}
+```
+
+- [ ] **Step 4: Implement expanded, compact, and mobile sidebar rendering**
+
+Add a real toggle with `aria-expanded={!collapsed}`, localized expand/collapse labels, the full wordmark when expanded, the mark when compact, grouped primary and secondary lists, icon-only `title` attributes, route-selection close on mobile, and existing Escape handling. Secondary items use the existing registered paths and authorization-filtered list supplied by `ShellLayout`.
+
+- [ ] **Step 5: Add shell geometry and accessibility CSS**
+
+```css
+.app-shell { --sidebar-width: 248px; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); }
+.app-shell[data-sidebar-collapsed='true'] { --sidebar-width: 72px; }
+.application-rail__label { opacity: 1; white-space: nowrap; }
+.application-rail[data-collapsed='true'] .application-rail__label,
+.application-rail[data-collapsed='true'] .application-rail__group-label { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+@media (prefers-reduced-motion: reduce) { .app-shell, .application-rail { transition: none; } }
+```
+
+- [ ] **Step 6: Run sidebar tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/navigation-access.test.tsx`
+
+Expected: PASS with exactly three primary destinations and authorized secondary tools.
+
+- [ ] **Step 7: Commit the sidebar slice**
+
+```bash
+git add apps/web/src/components/sidebar-preference.ts apps/web/src/components/application-rail.tsx apps/web/src/styles/workspace-shell.css apps/web/test/application-rail.test.tsx apps/web/test/navigation-access.test.tsx
+git commit -m "feat(web): add adaptive workspace sidebar"
+```
+
+---
+
+### Task 2: Shell integration and dashboard-only canvas
+
+**Files:**
+- Modify: `apps/web/src/components/shell-layout.tsx`
+- Modify: `apps/web/src/styles/workspace-shell.css`
+- Test: `apps/web/test/application-rail.test.tsx`
+- Test: `apps/web/test/dashboard-workspace.test.tsx`
+
+**Interfaces:**
+- Consumes: `readSidebarCompactPreference`, `writeSidebarCompactPreference`, `filterNavigationItems`.
+- Produces: direct dashboard outlet with no `DashboardWorkspace` history wrapper.
+
+- [ ] **Step 1: Write failing shell tests**
+
+```tsx
+it('renders dashboard directly on the canvas without analysis history controls', async () => {
+ renderDashboard();
+ expect(await screen.findByRole('heading', { name: 'Bức tranh kinh doanh' })).toBeVisible();
+ expect(screen.queryByRole('button', { name: 'Phân tích mới' })).toBeNull();
+ expect(screen.queryByRole('searchbox', { name: 'Tìm kiếm lịch sử' })).toBeNull();
+});
+
+it('uses compact sidebar by default at tablet width only when no preference exists', () => {
+ setViewport('(min-width: 768px) and (max-width: 1023px)', true);
+ renderDashboard();
+ expect(screen.getByRole('navigation', { name: 'Điều hướng chính' })).toHaveAttribute(
+ 'data-collapsed',
+ 'true',
+ );
+});
+```
+
+- [ ] **Step 2: Run the shell tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/dashboard-workspace.test.tsx`
+
+Expected: FAIL because Dashboard is still wrapped by `DashboardWorkspace` and sidebar state is not shell-owned.
+
+- [ ] **Step 3: Integrate adaptive state and secondary navigation**
+
+In `ShellLayout`, initialize explicit preference first, otherwise use compact mode for 768–1023 pixels; persist only user-triggered changes. Derive the secondary list with:
+
+```ts
+const SECONDARY_KEYS = new Set(['inbox', 'reviews', 'administration']);
+const secondaryItems = filterNavigationItems(accessContext).filter((item) =>
+ SECONDARY_KEYS.has(item.key),
+);
+```
+
+Pass `collapsed`, `onCollapsedChange`, and `secondaryItems` to `ApplicationRail`, and set `data-sidebar-collapsed` on `.app-shell`.
+
+- [ ] **Step 4: Remove the dashboard history wrapper from the route**
+
+Replace the conditional `DashboardWorkspace` wrapper with a direct ``. Keep the `DashboardWorkspace` component and its focused legacy tests intact until its governed history behavior is deliberately retired elsewhere; it is simply no longer mounted inside Dashboard.
+
+- [ ] **Step 5: Make the dashboard workspace fill the canvas**
+
+Set `.main-workspace--dashboard` to a pale blue canvas with no nested history grid, min-width zero, and responsive padding. Remove dashboard-specific rail overrides that contradict the shared adaptive sidebar.
+
+- [ ] **Step 6: Run shell and dashboard tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/application-rail.test.tsx test/dashboard-workspace.test.tsx test/dashboard-canvas.test.tsx`
+
+Expected: PASS; Dashboard contains no analysis-history controls and canvas authoring remains functional.
+
+- [ ] **Step 7: Commit the shell integration slice**
+
+```bash
+git add apps/web/src/components/shell-layout.tsx apps/web/src/styles/workspace-shell.css apps/web/test/application-rail.test.tsx apps/web/test/dashboard-workspace.test.tsx
+git commit -m "feat(web): restore dashboard-only canvas"
+```
+
+---
+
+### Task 3: Shared authorized conversation state and chat shell
+
+**Files:**
+- Modify: `apps/web/src/features/agent/agent-store.ts`
+- Create: `apps/web/src/features/agent/agent-chat-shell.tsx`
+- Modify: `apps/web/src/features/analysis/analysis-route-page.tsx`
+- Modify: `apps/web/src/features/agent/floating-agent-panel.tsx`
+- Modify: `apps/web/src/styles/workspace-shell.css`
+- Test: `apps/web/test/floating-agent.test.tsx`
+- Test: `apps/web/test/analysis-destination.test.tsx`
+
+**Interfaces:**
+- Produces: `AgentConversationSummaryV1`, `AgentMessagePresentationV1`, `setConversations`, `selectConversation`, and `getConversations`.
+- Produces: `AgentChatShell` props for conversations, active ID, messages, context, composer, select, create, submit, and Analysis link.
+- Consumes: authorized `DdaConversationSummary` and `DdaConversationLoadAccepted` results.
+
+- [ ] **Step 1: Write failing store and panel tests**
+
+```tsx
+it('switches only among supplied authorized conversations', async () => {
+ const user = userEvent.setup();
+ render( undefined} />);
+ await user.click(screen.getByRole('button', { name: 'Đơn hàng bất thường' }));
+ expect(store.getActiveConversation()?.conversationId).toBe('conversation-orders');
+ expect(screen.getByText('Ngữ cảnh: Tồn kho cửa hàng')).toBeVisible();
+});
+
+it('opens the selected conversation in full Analysis', () => {
+ renderPanel();
+ expect(screen.getByRole('link', { name: 'Mở trong Phân tích' })).toHaveAttribute(
+ 'href',
+ '/vi-VN/analysis?conversation=conversation-sales',
+ );
+});
+```
+
+- [ ] **Step 2: Run the focused tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/floating-agent.test.tsx test/analysis-destination.test.tsx`
+
+Expected: FAIL because the store exposes only one active conversation and the compact panel has no switcher or chat shell.
+
+- [ ] **Step 3: Extend the shared store without persisting authority**
+
+```ts
+export interface AgentConversationSummaryV1 {
+ readonly conversationId: string;
+ readonly title: string;
+ readonly datasetLabel: string;
+ readonly datasetVersionLabel: string;
+}
+
+export interface AgentStoreV1 {
+ getConversations(): readonly AgentConversationSummaryV1[];
+ setConversations(items: readonly AgentConversationSummaryV1[]): void;
+ selectConversation(conversationId: string): void;
+}
+```
+
+`selectConversation` must ignore unknown IDs. Conversation summaries stay in memory and are replaced or cleared when authoritative history is unavailable.
+
+- [ ] **Step 4: Build the reusable chat shell**
+
+Render a compact conversation dropdown/list, new conversation button, authorized context summary, scrollable user/assistant messages, a labeled textarea, loading/denial/unavailable states, send button, and Analysis deep link. Keep focus trap/return behavior in the containing panel.
+
+- [ ] **Step 5: Synchronize Analysis history into the store**
+
+When the authorized history query succeeds, map all authorized summaries into `setConversations`; when it fails or becomes empty, clear them. Selecting a conversation from Analysis or the compact agent uses the same active ID, while URL search parameters remain the full Analysis source of navigation truth.
+
+- [ ] **Step 6: Compose Data floating agent with the chat shell**
+
+Preserve the current Data context and fail-closed behavior. Do not fabricate messages or enable send when no authorized conversation/send callback exists. The new-conversation action links to or opens Analysis unless a real creation callback is supplied.
+
+- [ ] **Step 7: Run focused tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/floating-agent.test.tsx test/analysis-destination.test.tsx`
+
+Expected: PASS for switching, context restoration, Analysis link, unavailable history, and no fabricated message behavior.
+
+- [ ] **Step 8: Commit shared agent foundations**
+
+```bash
+git add apps/web/src/features/agent apps/web/src/features/analysis/analysis-route-page.tsx apps/web/src/styles/workspace-shell.css apps/web/test/floating-agent.test.tsx apps/web/test/analysis-destination.test.tsx
+git commit -m "feat(web): share authorized agent conversations"
+```
+
+---
+
+### Task 4: Dashboard agent chat and explicit chart insertion
+
+**Files:**
+- Modify: `apps/web/src/features/dashboards/dashboard-agent-panel.tsx`
+- Modify: `apps/web/src/features/dashboards/dashboard-page.tsx`
+- Modify: `apps/web/src/features/dashboards/dashboard-page.css`
+- Test: `apps/web/test/dashboard-agent-panel.test.tsx`
+- Test: `apps/web/test/chart-proposal-picker.test.tsx`
+- Test: `apps/web/test/dashboard-canvas.test.tsx`
+
+**Interfaces:**
+- Consumes: `AgentChatShell`, `workspaceAgentStore`, existing `askForChart`, `acceptCharts`, and `DashboardAuthoringCommandQueueV1`.
+- Produces: a Notion-like chat panel whose proposal confirmation calls the existing governed authoring path exactly once.
+
+- [ ] **Step 1: Write failing dashboard agent tests**
+
+```tsx
+it('shows conversation history and keeps proposals inside the assistant turn', async () => {
+ renderAgentPanel({ conversations, messages, proposalOptions });
+ expect(screen.getByRole('button', { name: 'Bức tranh kinh doanh' })).toBeVisible();
+ expect(screen.getByText('Cho tôi xem doanh thu theo khu vực')).toBeVisible();
+ expect(screen.getByRole('checkbox', { name: 'So sánh theo nhóm' })).toBeVisible();
+});
+
+it('names the exact mutation and does not add a chart before confirmation', async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+ await openProposalAndSelectTwo(user);
+ expect(screen.getByRole('button', { name: 'Thêm 2 biểu đồ vào canvas' })).toBeVisible();
+ expect(screen.queryByText('Biểu đồ được đề xuất')).toBeNull();
+ await user.click(screen.getByRole('button', { name: 'Thêm 2 biểu đồ vào canvas' }));
+ expect(await screen.findByText('Biểu đồ được đề xuất')).toBeVisible();
+});
+```
+
+- [ ] **Step 2: Run dashboard agent tests and confirm RED**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/dashboard-agent-panel.test.tsx test/chart-proposal-picker.test.tsx test/dashboard-canvas.test.tsx`
+
+Expected: FAIL because the current panel is form-like and lacks conversation/message composition.
+
+- [ ] **Step 3: Compose the panel around `AgentChatShell`**
+
+Keep the proposal picker within the latest assistant response. Add exact selected-count copy:
+
+```ts
+const confirmLabel = locale === 'vi-VN'
+ ? `Thêm ${selectedOptionIds.length} biểu đồ vào canvas`
+ : `Add ${selectedOptionIds.length} charts to canvas`;
+```
+
+Do not call `onConfirmProposal` from selection or agent response rendering.
+
+- [ ] **Step 4: Bind dashboard chat presentation state**
+
+Append the submitted question as a user presentation message, call existing `askForChart`, then append a concise assistant explanation carrying proposal cards. In demo mode, use only the existing deterministic demo proposal values. In live mode, show only returned governed results and clear/disable mutation controls on unavailable or rejected responses.
+
+- [ ] **Step 5: Preserve governed insertion and focus behavior**
+
+Call existing `acceptCharts(selectedOptionIds)` only from the explicit confirm button. After success, set the canvas focus target to the first inserted stable widget ID and announce saving/saved/conflict status through the existing authoring state.
+
+- [ ] **Step 6: Style the panel as a responsive AI chat**
+
+Use a 420-pixel wide desktop panel, right sheet at medium widths, and full-height bottom sheet on narrow screens. Use white/blue-gray surfaces, restrained borders, distinct user/assistant message alignment, visible context, and no gradients.
+
+- [ ] **Step 7: Run dashboard agent tests and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/dashboard-agent-panel.test.tsx test/chart-proposal-picker.test.tsx test/dashboard-canvas.test.tsx`
+
+Expected: PASS for conversation switching, message submission, proposal selection, explicit confirmation, and no silent mutation.
+
+- [ ] **Step 8: Commit dashboard agent integration**
+
+```bash
+git add apps/web/src/features/dashboards/dashboard-agent-panel.tsx apps/web/src/features/dashboards/dashboard-page.tsx apps/web/src/features/dashboards/dashboard-page.css apps/web/test/dashboard-agent-panel.test.tsx apps/web/test/chart-proposal-picker.test.tsx apps/web/test/dashboard-canvas.test.tsx
+git commit -m "feat(web): add conversational dashboard agent"
+```
+
+---
+
+### Task 5: Premium organization for Data, Reviews, Inbox, Analysis, and Settings
+
+**Files:**
+- Modify: `apps/web/src/features/analysis/analysis-page.css`
+- Modify: `apps/web/src/features/data/data-workspace.css`
+- Modify: `apps/web/src/styles/data-intake.css`
+- Modify: `apps/web/src/features/settings/workspace-settings.css`
+- Modify only where needed for semantic hierarchy: corresponding route page `.tsx` files.
+- Test: `apps/web/test/analysis-destination.test.tsx`
+- Test: `apps/web/test/data-pipeline-route.test.tsx`
+- Test: `apps/web/test/workspace-settings-route.test.tsx`
+- Test: route tests for Inbox/Data already present under `apps/web/test/`.
+
+**Interfaces:**
+- Consumes: shared sidebar and shell canvas tokens.
+- Produces: consistent title, description, action, primary work surface, and evidence hierarchy across all first-party routes.
+
+- [ ] **Step 1: Add failing hierarchy and state tests**
+
+For each route, assert one `h1`, a concise explanatory description, visible primary work surface, and distinct unavailable/empty state. Assert that Reviews keeps the enabled CSV/XLSX upload path when local/demo intake is configured and that Settings keeps member/session controls operational.
+
+- [ ] **Step 2: Run the focused page suites and confirm RED where hierarchy is missing**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/analysis-destination.test.tsx test/data-pipeline-route.test.tsx test/workspace-settings-route.test.tsx test/data-workspace.test.tsx`
+
+Expected: only pages missing the approved semantic hierarchy fail.
+
+- [ ] **Step 3: Normalize page composition and visual tokens**
+
+Use one compact heading block, state/action row, white primary workspace, and quieter supporting evidence. Remove decorative over-cardification, green primary actions, mixed radius scales, and unnecessary pill wrappers. Keep success green and warnings amber only for semantic statuses; primary actions and selection remain cobalt.
+
+- [ ] **Step 4: Verify responsive and accessibility rules in CSS**
+
+Add 200-percent zoom-safe wrapping, focus-visible rings, forced-color borders, reduced motion, and mobile stacking without horizontal clipping.
+
+- [ ] **Step 5: Run focused page suites and confirm GREEN**
+
+Run: `corepack pnpm --filter @databreeze/web exec vitest run test/analysis-destination.test.tsx test/data-pipeline-route.test.tsx test/workspace-settings-route.test.tsx test/data-workspace.test.tsx`
+
+Expected: PASS with live controls preserved and unavailable states not presented as empty data.
+
+- [ ] **Step 6: Commit page organization**
+
+```bash
+git add apps/web/src/features/analysis apps/web/src/features/data apps/web/src/features/data-intake apps/web/src/features/settings apps/web/src/styles/data-intake.css apps/web/test
+git commit -m "feat(web): unify premium workspace pages"
+```
+
+---
+
+### Task 6: Full verification and local runtime proof
+
+**Files:**
+- Modify only for defects revealed by verification.
+- Verify: `apps/web` and local Web container.
+
+**Interfaces:**
+- Consumes: all prior tasks.
+- Produces: tested local Web behavior and evidence for handoff.
+
+- [ ] **Step 1: Run the complete Web unit suite**
+
+Run: `corepack pnpm --filter @databreeze/web test`
+
+Expected: every Vitest file passes with zero failures.
+
+- [ ] **Step 2: Run TypeScript and production build**
+
+Run: `corepack pnpm --filter @databreeze/web typecheck`
+
+Expected: exit 0.
+
+Run: `corepack pnpm --filter @databreeze/web build`
+
+Expected: exit 0 and the existing 256,000-byte gzip initial JavaScript budget passes.
+
+- [ ] **Step 3: Run formatting and whitespace checks**
+
+Run: `corepack pnpm exec prettier --check apps/web/src apps/web/test docs/plans/409-adaptive-workspace-shell-agent-implementation.md`
+
+Expected: exit 0.
+
+Run: `git diff --check`
+
+Expected: no whitespace errors in the owned slice.
+
+- [ ] **Step 4: Rebuild and restart only the local Web service**
+
+Run: `docker compose --env-file infrastructure/local/.env.local -f infrastructure/local/compose.yml --profile app build web`
+
+Expected: exit 0.
+
+Run: `docker compose --env-file infrastructure/local/.env.local -f infrastructure/local/compose.yml --profile app up -d --no-deps web gateway`
+
+Expected: Web and gateway become healthy without replacing PostgreSQL data.
+
+- [ ] **Step 5: Verify local routes and API routing**
+
+Run: `Invoke-WebRequest -SkipCertificateCheck https://localhost:8443/vi-VN/dashboards | Select-Object StatusCode,Headers`
+
+Expected: 200 `text/html`.
+
+Run: `try { Invoke-WebRequest -SkipCertificateCheck https://localhost:8443/v1/me/bootstrap } catch { $_.Exception.Response.StatusCode.value__ }`
+
+Expected: 401 for a signed-out browser, proving `/v1` is API-routed rather than rewritten to the SPA.
+
+- [ ] **Step 6: Perform signed-in browser acceptance**
+
+At desktop and narrow widths, verify: wordmark-expanded sidebar; compact icon mode; preference across reload; exactly three primary destinations; authorized secondary tools; no Dashboard history panel or `Phân tích mới`; pale-blue full canvas; agent conversation switching; contextual messages; explicit chart confirmation; inserted widget focus; Data/Reviews/Settings page organization; login/logout and route protection.
+
+- [ ] **Step 7: Commit verified implementation**
+
+```bash
+git add docs/plans/409-adaptive-workspace-shell-agent-implementation.md apps/web
+git commit -m "feat(web): deliver adaptive premium workspace"
+```
+
+---
+
+## Self-Review
+
+- Spec coverage: Tasks 1–2 cover adaptive navigation and dashboard-only canvas; Tasks 3–4 cover shared conversations, chat, and explicit chart insertion; Task 5 covers all named secondary pages; Task 6 covers accessibility, bundle, local runtime, and signed-in acceptance.
+- Authorization: navigation is only a client hint; API and generated-contract boundaries remain authoritative.
+- Mutation safety: chart selection does not mutate; the exact confirm action invokes the existing governed authoring command path.
+- Type consistency: shared store and chat shell names are defined in Task 3 and consumed unchanged in Task 4.
+- No backend values, prior messages, tenant scope, or success state are fabricated when live services are unavailable.
From 25a39aa4b62a34477717da4b099449ed3d40527b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Fri, 14 Aug 2026 20:28:21 +0700
Subject: [PATCH 03/11] feat(web): add adaptive workspace sidebar
---
apps/web/src/components/application-rail.tsx | 117 +++--
apps/web/src/components/shell-layout.tsx | 58 ++-
apps/web/src/components/sidebar-preference.ts | 20 +
apps/web/src/styles/workspace-shell.css | 416 +++++++-----------
apps/web/test/application-rail.test.tsx | 68 ++-
apps/web/test/navigation-access.test.tsx | 2 +
6 files changed, 366 insertions(+), 315 deletions(-)
create mode 100644 apps/web/src/components/sidebar-preference.ts
diff --git a/apps/web/src/components/application-rail.tsx b/apps/web/src/components/application-rail.tsx
index 1752a690..58ce1116 100644
--- a/apps/web/src/components/application-rail.tsx
+++ b/apps/web/src/components/application-rail.tsx
@@ -1,18 +1,25 @@
import wordmarkUrl from '@databreeze/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png';
+import { formatMessageV1 } from '@databreeze/i18n/v1';
import { useEffect } from 'react';
import { Link, NavLink } from 'react-router-dom';
+import { getFeatureRegistration } from '../app/feature-registry.ts';
import { appMessage } from '../app/messages.ts';
+import type { NavigationItem } from '../app/navigation.ts';
import {
udwPrimaryNavLabelV1,
type UdwPrimaryNavItemV1,
} from '../app/unified-primary-navigation.ts';
+import { BellIcon, MenuIcon, SearchIcon, XIcon } from './icons.tsx';
export interface ApplicationRailProperties {
+ readonly collapsed?: boolean;
readonly isMobile?: boolean;
readonly items: readonly UdwPrimaryNavItemV1[];
readonly locale: 'en' | 'vi-VN';
readonly mobileOpen: boolean;
+ readonly onCollapsedChange?: (collapsed: boolean) => void;
readonly onMobileOpenChange: (open: boolean) => void;
+ readonly secondaryItems?: readonly NavigationItem[];
}
function RailIcon({ itemKey }: { readonly itemKey: UdwPrimaryNavItemV1['key'] }) {
@@ -56,31 +63,29 @@ function RailIcon({ itemKey }: { readonly itemKey: UdwPrimaryNavItemV1['key'] })
);
}
-function CloseIcon() {
- return (
-
- );
+function SecondaryIcon({ itemKey }: { readonly itemKey: NavigationItem['key'] }) {
+ if (itemKey === 'inbox') return ;
+ if (itemKey === 'reviews') return ;
+ return ;
+}
+
+function secondaryLabel(locale: 'en' | 'vi-VN', item: NavigationItem): string {
+ const registration = getFeatureRegistration(item.key);
+ return registration.messageKey === undefined
+ ? item.key
+ : formatMessageV1(locale, registration.messageKey);
}
/** WEB-002/013/014/022: compact, build-time registered primary navigation. */
export function ApplicationRail({
+ collapsed = false,
isMobile = false,
items,
locale,
mobileOpen,
+ onCollapsedChange = () => undefined,
onMobileOpenChange,
+ secondaryItems = [],
}: ApplicationRailProperties) {
useEffect(() => {
if (!isMobile || !mobileOpen) return undefined;
@@ -92,24 +97,51 @@ export function ApplicationRail({
return () => globalThis.removeEventListener('keydown', closeOnEscape);
}, [isMobile, mobileOpen, onMobileOpenChange]);
+ const effectivelyCollapsed = isMobile ? false : collapsed;
+ const collapseLabel =
+ locale === 'vi-VN'
+ ? effectivelyCollapsed
+ ? 'Mở rộng thanh bên'
+ : 'Thu gọn thanh bên'
+ : effectivelyCollapsed
+ ? 'Expand sidebar'
+ : 'Collapse sidebar';
+ const workspaceLabel = locale === 'vi-VN' ? 'Không gian làm việc' : 'Workspace';
+ const toolsLabel = locale === 'vi-VN' ? 'Công cụ' : 'Tools';
+
return (
);
}
diff --git a/apps/web/src/components/shell-layout.tsx b/apps/web/src/components/shell-layout.tsx
index d807cfc4..f8a8292f 100644
--- a/apps/web/src/components/shell-layout.tsx
+++ b/apps/web/src/components/shell-layout.tsx
@@ -2,48 +2,61 @@ import { useEffect, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { LocaleProvider, normalizeRouteLocale } from '../app/locale-context.tsx';
import { appMessage } from '../app/messages.ts';
-import type { WebAccessContext } from '../app/navigation.ts';
+import { filterNavigationItems, type WebAccessContext } from '../app/navigation.ts';
import { UDW_PRIMARY_NAV_ITEMS_V1 } from '../app/unified-primary-navigation.ts';
-import { DashboardWorkspace } from '../features/dashboards/dashboard-workspace.tsx';
import { createAuthApiV1 } from '../features/auth/auth-api.ts';
import { currentAuthBootstrapV1 } from '../features/auth/auth-session.ts';
import { ApplicationRail } from './application-rail.tsx';
+import {
+ readSidebarCompactPreference,
+ writeSidebarCompactPreference,
+} from './sidebar-preference.ts';
import { WorkspaceTopbar } from './workspace-topbar.tsx';
import '../styles/workspace-shell.css';
const MOBILE_QUERY = '(max-width: 767px)';
+const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023px)';
-function useIsMobile(): boolean {
- const [isMobile, setIsMobile] = useState(() =>
+function useMediaQuery(query: string): boolean {
+ const [matches, setMatches] = useState(() =>
typeof globalThis.matchMedia === 'function'
- ? globalThis.matchMedia(MOBILE_QUERY).matches
+ ? globalThis.matchMedia(query).matches
: false,
);
useEffect(() => {
if (typeof globalThis.matchMedia !== 'function') return undefined;
- const mediaQuery = globalThis.matchMedia(MOBILE_QUERY);
- const update = (event: MediaQueryListEvent) => setIsMobile(event.matches);
- setIsMobile(mediaQuery.matches);
+ const mediaQuery = globalThis.matchMedia(query);
+ const update = (event: MediaQueryListEvent) => setMatches(event.matches);
+ setMatches(mediaQuery.matches);
mediaQuery.addEventListener('change', update);
return () => mediaQuery.removeEventListener('change', update);
- }, []);
+ }, [query]);
- return isMobile;
+ return matches;
}
/** WEB-002/013/014/022: shared shell keeps routes and server-authorized feature boundaries intact. */
export function ShellLayout({ accessContext }: { readonly accessContext: WebAccessContext }) {
- void accessContext;
const { locale: routeLocale } = useParams();
const locale = normalizeRouteLocale(routeLocale);
const location = useLocation();
const navigate = useNavigate();
- const isMobile = useIsMobile();
+ const isMobile = useMediaQuery(MOBILE_QUERY);
+ const isTablet = useMediaQuery(TABLET_QUERY);
const [navigationOpen, setNavigationOpen] = useState(false);
+ const [sidebarPreference, setSidebarPreference] = useState(() =>
+ readSidebarCompactPreference(),
+ );
const bootstrap = currentAuthBootstrapV1();
const logicalPath = location.pathname.split('/').filter(Boolean).slice(1).join('/');
const isDashboardWorkspace = logicalPath === 'dashboards';
+ const isAnalysisWorkspace = logicalPath === 'analysis';
+ const sidebarCollapsed = !isMobile && (sidebarPreference ?? isTablet);
+ const secondaryKeys = new Set(['inbox', 'reviews', 'administration']);
+ const secondaryItems = filterNavigationItems(accessContext).filter((item) =>
+ secondaryKeys.has(item.key),
+ );
useEffect(() => {
setNavigationOpen(false);
@@ -54,7 +67,10 @@ export function ShellLayout({ accessContext }: { readonly accessContext: WebAcce
{appMessage(locale, 'skip.main')}
-
+
{
+ setSidebarPreference(collapsed);
+ writeSidebarCompactPreference(collapsed);
+ }}
onMobileOpenChange={setNavigationOpen}
+ secondaryItems={secondaryItems}
/>
- {isDashboardWorkspace ? (
-
-
-
- ) : (
-
- )}
+
diff --git a/apps/web/src/components/sidebar-preference.ts b/apps/web/src/components/sidebar-preference.ts
new file mode 100644
index 00000000..6f4c4212
--- /dev/null
+++ b/apps/web/src/components/sidebar-preference.ts
@@ -0,0 +1,20 @@
+const SIDEBAR_COMPACT_KEY = 'databreeze.sidebar.compact.v1';
+
+/** WEB-013: stores presentation only; never authority, tenant, or resource identity. */
+export function readSidebarCompactPreference(): boolean | undefined {
+ try {
+ const value = globalThis.localStorage?.getItem(SIDEBAR_COMPACT_KEY);
+ return value === 'true' ? true : value === 'false' ? false : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** WEB-013: device-local, bounded and content-free preference. */
+export function writeSidebarCompactPreference(compact: boolean): void {
+ try {
+ globalThis.localStorage?.setItem(SIDEBAR_COMPACT_KEY, String(compact));
+ } catch {
+ // A blocked storage provider must not make navigation unavailable.
+ }
+}
diff --git a/apps/web/src/styles/workspace-shell.css b/apps/web/src/styles/workspace-shell.css
index a90fda20..a2a39e4e 100644
--- a/apps/web/src/styles/workspace-shell.css
+++ b/apps/web/src/styles/workspace-shell.css
@@ -3,21 +3,22 @@
@import '@fontsource/be-vietnam-pro/600.css';
:root {
- --workspace-canvas: #fbfcfd;
- --workspace-canvas-border: #e4e9ef;
- --workspace-cobalt: #0f5fe7;
+ --workspace-canvas: #f4f7fc;
+ --workspace-canvas-border: #dce5f2;
+ --workspace-cobalt: #075de8;
--workspace-cobalt-deep: #0643bd;
- --workspace-ink: #101c2d;
+ --workspace-ink: #102a63;
--workspace-muted: #68778d;
--workspace-surface: #ffffff;
--workspace-transition: 240ms;
}
.app-shell.app-shell {
+ --sidebar-width: 248px;
min-height: 100vh;
display: grid;
grid-template-areas: 'rail topbar' 'rail main';
- grid-template-columns: 72px minmax(0, 1fr);
+ grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
grid-template-rows: 64px minmax(0, 1fr);
background: var(--workspace-canvas);
font-family:
@@ -29,6 +30,10 @@
sans-serif;
}
+.app-shell.app-shell[data-sidebar-collapsed='true'] {
+ --sidebar-width: 72px;
+}
+
.app-shell.app-shell--dashboard {
background: #fbfcfd;
}
@@ -172,10 +177,12 @@
min-height: 100vh;
display: flex;
flex-direction: column;
- align-items: center;
+ align-items: stretch;
overflow: hidden;
color: #ffffff;
background: var(--workspace-cobalt);
+ box-shadow: 8px 0 24px rgb(16 42 99 / 10%);
+ transition: width var(--workspace-transition) ease;
}
.application-rail::after {
@@ -188,12 +195,21 @@
background: var(--workspace-cobalt-deep);
}
+.application-rail__header {
+ min-height: 78px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px;
+}
+
.application-rail__brand {
- width: 44px;
+ min-width: 0;
+ flex: 1 1 auto;
height: 52px;
- display: grid;
- place-items: center;
- margin-block: 8px 12px;
+ display: flex;
+ align-items: center;
+ padding: 6px 10px;
border-radius: 14px;
}
@@ -203,39 +219,95 @@
}
.application-rail__brand-mark {
- width: 34px;
- height: 32px;
+ width: 174px;
+ height: 44px;
overflow: hidden;
display: block;
- padding: 4px;
+ padding: 7px 10px;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 5px 14px rgb(6 67 189 / 20%);
}
.application-rail__brand img {
- width: 131px;
- height: 32px;
- max-width: none;
+ width: 146px;
+ height: auto;
+ max-width: 100%;
display: block;
filter: none;
}
+.application-rail[data-collapsed='true'] .application-rail__header {
+ display: grid;
+ justify-items: center;
+ padding-inline: 8px;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__brand {
+ width: 48px;
+ padding: 6px;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__brand-mark {
+ width: 36px;
+ height: 36px;
+ padding: 4px;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__brand img {
+ width: 146px;
+ max-width: none;
+}
+
+.application-rail__collapse {
+ width: 36px;
+ height: 36px;
+ flex: 0 0 36px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 1px solid rgb(255 255 255 / 22%);
+ border-radius: 10px;
+ color: #ffffff;
+ background: rgb(255 255 255 / 10%);
+ cursor: pointer;
+}
+
+.application-rail__collapse:hover,
+.application-rail__collapse:focus-visible {
+ background: rgb(255 255 255 / 20%);
+}
+
+.application-rail[data-collapsed='true'] .application-rail__collapse {
+ grid-row: 2;
+}
+
+.application-rail__group-label {
+ margin: 12px 20px 7px;
+ color: rgb(255 255 255 / 62%);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
.application-rail__items {
width: 100%;
display: grid;
gap: 8px;
margin: 0;
- padding: 0 10px;
+ padding: 0 12px;
list-style: none;
}
.application-rail__link {
- min-width: 48px;
- min-height: 48px;
+ min-width: 0;
+ min-height: 44px;
display: flex;
align-items: center;
- justify-content: center;
+ justify-content: flex-start;
+ gap: 12px;
+ padding: 0 13px;
border: 1px solid transparent;
border-radius: 14px;
color: rgb(255 255 255 / 86%);
@@ -255,6 +327,38 @@
}
.application-rail__label {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 13px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.application-rail__secondary {
+ margin-top: auto;
+ padding-bottom: 18px;
+}
+
+.application-rail__items--secondary {
+ gap: 4px;
+}
+
+.application-rail__items--secondary .application-rail__link {
+ color: rgb(255 255 255 / 74%);
+}
+
+.application-rail[data-collapsed='true'] .application-rail__items {
+ padding-inline: 10px;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__link {
+ justify-content: center;
+ padding-inline: 0;
+}
+
+.application-rail[data-collapsed='true'] .application-rail__label,
+.application-rail[data-collapsed='true'] .application-rail__group-label {
position: absolute;
width: 1px;
height: 1px;
@@ -280,6 +384,12 @@
padding: 0;
}
+.app-shell .main-workspace--analysis {
+ min-height: 0;
+ padding: 0;
+ overflow: hidden;
+}
+
.dashboard-workspace {
min-height: calc(100vh - 64px);
display: grid;
@@ -517,12 +627,16 @@
box-shadow: 14px 0 30px rgb(21 47 117 / 24%);
}
+ .application-rail--mobile .application-rail__header {
+ padding: 0 52px 0 0;
+ }
+
.application-rail--mobile[hidden] {
display: none;
}
.application-rail__brand {
- margin-inline-start: 2px;
+ max-width: 204px;
}
.application-rail__mobile-close {
@@ -611,71 +725,15 @@
}
}
-/* Approved dashboard composition: the rail and analysis history own the full height,
- while the dashboard context bar starts at the canvas edge. DDA-020/026, WEB-013. */
+/* WEB-013/DDA-020: Dashboard uses the shared shell and the full pale-blue canvas. */
.app-shell.app-shell--dashboard {
- --dashboard-rail-width: 80px;
- --dashboard-history-width: clamp(300px, 22vw, 360px);
- height: 100vh;
- overflow: hidden;
- grid-template-areas: 'rail main';
- grid-template-columns: var(--dashboard-rail-width) minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr);
- background: #f7faff;
-}
-
-.app-shell--dashboard .application-rail {
- height: 100vh;
- min-height: 0;
- background: #0d5de5;
- box-shadow: 10px 0 30px rgb(15 83 207 / 12%);
-}
-
-.app-shell--dashboard .application-rail::after {
- display: none;
-}
-
-.app-shell--dashboard .application-rail__brand {
- visibility: hidden;
- margin-block: 40px 28px;
-}
-
-.app-shell--dashboard .application-rail__items {
- gap: 24px;
- padding-inline: 12px;
-}
-
-.app-shell--dashboard .application-rail__link {
- min-width: 56px;
- min-height: 56px;
- border-radius: 16px;
-}
-
-.app-shell--dashboard .application-rail__link.is-active {
- border-color: #ffffff;
- color: #0d5de5;
- background: #ffffff;
- box-shadow: 0 12px 28px rgb(3 41 117 / 26%);
+ min-height: 100vh;
+ background: #f4f7fc;
}
.app-shell--dashboard .workspace-topbar--dashboard {
- position: fixed;
- z-index: 50;
- inset-block-start: 0;
- inset-inline-start: calc(var(--dashboard-rail-width) + var(--dashboard-history-width));
- inset-inline-end: 0;
- height: 68px;
- padding-inline: clamp(20px, 2.5vw, 40px);
- border-bottom-color: #dce5f2;
- background: rgb(255 255 255 / 94%);
- box-shadow: 0 6px 20px rgb(22 67 146 / 4%);
- backdrop-filter: blur(16px);
- transition: inset-inline-start var(--workspace-transition) var(--db-motion-easing-standard);
-}
-
-.app-shell--dashboard:has(.dashboard-workspace[data-history-collapsed='true'])
- .workspace-topbar--dashboard {
- inset-inline-start: var(--dashboard-rail-width);
+ background: #ffffff;
+ box-shadow: 0 4px 8px rgb(16 42 99 / 4%);
}
.workspace-topbar__dashboard-breadcrumb {
@@ -728,146 +786,10 @@
}
.app-shell--dashboard .main-workspace--dashboard {
- grid-area: main;
- height: 100vh;
- min-height: 0;
- overflow: hidden;
-}
-
-.app-shell--dashboard .dashboard-workspace,
-.app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'] {
- height: 100vh;
- min-height: 0;
- grid-template-columns: var(--dashboard-history-width) minmax(0, 1fr);
- background: #f7faff;
-}
-
-.app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'] {
- grid-template-columns: 0 minmax(0, 1fr);
-}
-
-.app-shell--dashboard .analysis-history-panel {
- position: relative;
- height: 100vh;
- padding: 20px 24px 28px;
- overflow-y: auto;
- border-inline-end-color: #dfe6f1;
- background: #ffffff;
-}
-
-.analysis-history-panel__brand {
- min-height: 84px;
- display: grid;
- place-items: center;
- padding: 16px;
- border: 1px solid #edf1f8;
- border-radius: 18px;
- background: #ffffff;
- box-shadow: 0 12px 28px rgb(24 67 143 / 8%);
-}
-
-.analysis-history-panel__brand img {
- width: min(204px, 100%);
- height: auto;
- display: block;
-}
-
-.app-shell--dashboard .analysis-history-panel__header {
- min-height: 0;
-}
-
-.app-shell--dashboard .analysis-history-panel__header > div {
- position: absolute;
- width: 1px;
- height: 1px;
- margin: -1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
-}
-
-.app-shell--dashboard .analysis-history-panel__collapse {
- position: absolute;
- inset-block-start: 28px;
- inset-inline-end: 30px;
- width: 32px;
- min-width: 32px;
- min-height: 32px;
- border-color: transparent;
- background: rgb(255 255 255 / 88%);
- opacity: 0;
- transition: opacity 150ms ease;
-}
-
-.app-shell--dashboard .analysis-history-panel:hover .analysis-history-panel__collapse,
-.app-shell--dashboard .analysis-history-panel__collapse:focus-visible {
- opacity: 1;
-}
-
-.app-shell--dashboard .analysis-history-panel__create {
- min-height: 52px;
- margin-block: 18px 24px;
- border-radius: 14px;
- background: #0d5de5;
- box-shadow: 0 10px 22px rgb(13 93 229 / 22%);
- font-size: 15px;
- font-weight: 700;
-}
-
-.app-shell--dashboard .analysis-history-panel__search-label {
- position: absolute;
- width: 1px;
- height: 1px;
- margin: -1px;
- overflow: hidden;
- clip: rect(0 0 0 0);
- white-space: nowrap;
-}
-
-.app-shell--dashboard .analysis-history-panel__search {
- min-height: 40px;
- padding-inline: 14px;
- border-color: #e3eaf4;
- border-radius: 12px;
- background: #f8faff;
-}
-
-.app-shell--dashboard .analysis-history-panel__items {
- gap: 10px;
- margin-block-start: 22px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item {
- gap: 5px;
- padding: 14px;
- border-radius: 12px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item-kind {
- display: none;
-}
-
-.app-shell--dashboard .analysis-history-panel__item-title {
- color: #152447;
- font-size: 14px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item-updated {
- color: #73829f;
- font-size: 12px;
-}
-
-.app-shell--dashboard .analysis-history-panel__item.is-active {
- border-color: transparent;
- background: #eef4ff;
- box-shadow: inset 3px 0 0 #0d5de5;
-}
-
-.app-shell--dashboard .dashboard-workspace__stage {
- height: 100vh;
- padding: 88px clamp(18px, 2vw, 32px) 28px;
+ min-width: 0;
+ padding: clamp(18px, 2.4vw, 32px);
overflow: auto;
- background: #f7faff;
+ background: #f4f7fc;
}
.workspace-topbar--dashboard[data-dashboard-presentation='vertical']
@@ -890,57 +812,19 @@
}
}
-@media (max-width: 1023px) {
- .app-shell.app-shell--dashboard {
- --dashboard-history-width: min(360px, calc(100vw - 100px));
- }
-
- .app-shell--dashboard .workspace-topbar--dashboard {
- inset-inline-start: var(--dashboard-rail-width);
- }
-
- .app-shell--dashboard .dashboard-workspace,
- .app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'] {
- grid-template-columns: minmax(0, 1fr);
- }
-}
-
@media (max-width: 767px) {
- .app-shell.app-shell--dashboard {
- height: auto;
- min-height: 100vh;
- overflow: visible;
- grid-template-areas: 'topbar' 'main';
- grid-template-columns: minmax(0, 1fr);
- grid-template-rows: 60px minmax(0, 1fr);
- }
-
- .app-shell--dashboard .workspace-topbar--dashboard {
- position: static;
- grid-area: topbar;
- height: 60px;
- padding-inline: 12px;
- }
-
.app-shell--dashboard .workspace-topbar__dashboard-breadcrumb strong {
display: none;
}
- .app-shell--dashboard .main-workspace--dashboard,
- .app-shell--dashboard .dashboard-workspace,
- .app-shell--dashboard .dashboard-workspace[data-history-collapsed='true'],
- .app-shell--dashboard .dashboard-workspace__stage {
- height: auto;
- min-height: 0;
- overflow: visible;
- }
-
- .app-shell--dashboard .dashboard-workspace__stage {
- padding: 14px;
+ .app-shell--dashboard .main-workspace--dashboard {
+ padding: 12px;
}
+}
- .app-shell--dashboard .analysis-history-panel--mobile {
- height: 100dvh;
- padding: 18px;
+@media (prefers-reduced-motion: reduce) {
+ .app-shell,
+ .application-rail {
+ transition: none;
}
}
diff --git a/apps/web/test/application-rail.test.tsx b/apps/web/test/application-rail.test.tsx
index 9c85784f..9533329d 100644
--- a/apps/web/test/application-rail.test.tsx
+++ b/apps/web/test/application-rail.test.tsx
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
function renderDashboard(pathname = '/vi-VN/dashboards') {
@@ -9,6 +10,14 @@ function renderDashboard(pathname = '/vi-VN/dashboards') {
}
describe('application rail', () => {
+ beforeEach(() => {
+ globalThis.localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
it('keeps the three primary destinations as labeled icon navigation and marks the current route', async () => {
renderDashboard();
@@ -32,4 +41,61 @@ describe('application rail', () => {
expect(screen.getByText('Bright Cloud')).toBeTruthy();
expect(screen.getByText('Bức tranh kinh doanh')).toBeTruthy();
});
+
+ it('starts expanded, collapses to icon-only navigation, and remembers the preference', async () => {
+ const user = userEvent.setup();
+ renderDashboard();
+
+ await screen.findByRole('navigation', { name: 'Điều hướng chính' });
+ expect(screen.getByRole('link', { name: 'Bảng điều khiển' }).textContent).toContain(
+ 'Bảng điều khiển',
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Thu gọn thanh bên' }));
+
+ const navigation = screen.getByRole('navigation', { name: 'Điều hướng chính' });
+ expect(navigation.getAttribute('data-collapsed')).toBe('true');
+ expect(globalThis.localStorage.getItem('databreeze.sidebar.compact.v1')).toBe('true');
+ expect(screen.getByRole('link', { name: 'Bảng điều khiển' }).getAttribute('title')).toBe(
+ 'Bảng điều khiển',
+ );
+ });
+
+ it('renders authorized Inbox, Reviews, and Settings as quieter workspace tools', async () => {
+ renderDashboard();
+
+ await screen.findByRole('navigation', { name: 'Điều hướng chính' });
+ expect(screen.getByRole('link', { name: 'Hộp thư đến' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Nội dung cần xem xét' })).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Cài đặt' })).toBeTruthy();
+ });
+
+ it('uses compact sidebar by default at tablet width when no preference exists', async () => {
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ matches: query === '(min-width: 768px) and (max-width: 1023px)',
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+
+ renderDashboard();
+
+ expect(
+ (await screen.findByRole('navigation', { name: 'Điều hướng chính' })).getAttribute(
+ 'data-collapsed',
+ ),
+ ).toBe('true');
+ });
+
+ it('keeps the Dashboard canvas free of analysis-history controls', async () => {
+ renderDashboard();
+
+ await screen.findByRole('region', { name: 'Bề mặt bảng điều khiển' });
+ expect(screen.queryByRole('button', { name: 'Phân tích mới' })).toBeNull();
+ expect(screen.queryByLabelText('Tìm lịch sử phân tích')).toBeNull();
+ });
});
diff --git a/apps/web/test/navigation-access.test.tsx b/apps/web/test/navigation-access.test.tsx
index a9d6fb4d..6ce13dbe 100644
--- a/apps/web/test/navigation-access.test.tsx
+++ b/apps/web/test/navigation-access.test.tsx
@@ -35,6 +35,8 @@ describe('build-time governed navigation', () => {
expect(screen.getByRole('link', { name: 'Data' })).toBeTruthy();
expect(screen.queryByRole('link', { name: 'Jobs' })).toBeNull();
expect(screen.queryByRole('link', { name: 'Devices' })).toBeNull();
+ expect(screen.getByRole('link', { name: 'Reviews' })).toBeTruthy();
+ expect(screen.queryByRole('link', { name: 'Settings' })).toBeNull();
expect(UDW_PRIMARY_NAV_ITEMS_V1).toHaveLength(3);
});
From 17a82dcc2a9344ad62d4469792b8d218cf5c3905 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Fri, 14 Aug 2026 20:33:46 +0700
Subject: [PATCH 04/11] feat(web): share authorized agent conversations
---
.../src/features/agent/agent-chat-shell.tsx | 168 +++++++++++++++
apps/web/src/features/agent/agent-store.ts | 63 ++++++
.../features/agent/floating-agent-panel.tsx | 56 ++++-
.../features/analysis/analysis-route-page.tsx | 74 ++++++-
apps/web/src/styles/workspace-shell.css | 191 ++++++++++++++++++
apps/web/test/floating-agent.test.tsx | 66 ++++++
apps/web/test/workspace-agent-store.test.ts | 29 +++
7 files changed, 631 insertions(+), 16 deletions(-)
create mode 100644 apps/web/src/features/agent/agent-chat-shell.tsx
diff --git a/apps/web/src/features/agent/agent-chat-shell.tsx b/apps/web/src/features/agent/agent-chat-shell.tsx
new file mode 100644
index 00000000..6e4dc1f3
--- /dev/null
+++ b/apps/web/src/features/agent/agent-chat-shell.tsx
@@ -0,0 +1,168 @@
+import { useState, type FormEvent, type ReactNode, type Ref } from 'react';
+import { Link } from 'react-router-dom';
+
+import type { AgentConversationSummaryV1 } from './agent-store.ts';
+
+export interface AgentMessagePresentationV1 {
+ readonly messageId: string;
+ readonly role: 'USER' | 'ASSISTANT';
+ readonly text: string;
+ readonly createdLabel?: string;
+}
+
+export interface AgentChatShellProperties {
+ readonly activeConversationId?: string;
+ readonly analysisHref: string;
+ readonly children?: ReactNode;
+ readonly composerLabel?: string;
+ readonly context?: string;
+ readonly conversations: readonly AgentConversationSummaryV1[];
+ readonly locale: 'en' | 'vi-VN';
+ readonly messages?: readonly AgentMessagePresentationV1[];
+ readonly newConversationHref?: string;
+ readonly onCreateConversation?: () => void;
+ readonly onSelectConversation: (conversationId: string) => void;
+ readonly onSubmitMessage?: (message: string) => void | Promise;
+ readonly stateMessage?: string;
+ readonly stateTone?: 'status' | 'alert';
+ readonly submitting?: boolean;
+ readonly textareaRef?: Ref;
+}
+
+/** WEB-024/DDA-055: one content-safe chat presentation shared by compact agent surfaces. */
+export function AgentChatShell({
+ activeConversationId,
+ analysisHref,
+ children,
+ composerLabel,
+ context,
+ conversations,
+ locale,
+ messages = [],
+ newConversationHref,
+ onCreateConversation,
+ onSelectConversation,
+ onSubmitMessage,
+ stateMessage,
+ stateTone = 'status',
+ submitting = false,
+ textareaRef,
+}: AgentChatShellProperties) {
+ const [draft, setDraft] = useState('');
+ const text =
+ locale === 'vi-VN'
+ ? {
+ analysis: 'Mở trong Phân tích',
+ composer: composerLabel ?? 'Nhập câu hỏi cho trợ lý',
+ empty: 'Chưa có tin nhắn trong hội thoại này.',
+ inputPlaceholder: 'Hỏi về dữ liệu hoặc yêu cầu một biểu đồ…',
+ newConversation: 'Hội thoại mới',
+ noConversation: 'Chưa có hội thoại được cấp quyền.',
+ send: submitting ? 'Đang gửi…' : 'Gửi',
+ switchConversation: 'Chuyển hội thoại',
+ }
+ : {
+ analysis: 'Open in Analysis',
+ composer: composerLabel ?? 'Ask the agent',
+ empty: 'There are no messages in this conversation yet.',
+ inputPlaceholder: 'Ask about your data or request a chart…',
+ newConversation: 'New conversation',
+ noConversation: 'No authorized conversations are available.',
+ send: submitting ? 'Sending…' : 'Send',
+ switchConversation: 'Switch conversation',
+ };
+
+ async function submit(event: FormEvent) {
+ event.preventDefault();
+ const message = draft.trim();
+ if (message === '' || submitting || onSubmitMessage === undefined) return;
+ await onSubmitMessage(message);
+ setDraft('');
+ }
+
+ return (
+
+
+
+ {onCreateConversation === undefined ? (
+
+ {text.newConversation}
+
+ ) : (
+
+ )}
+
+
+ {context === undefined ? null :
{context}
}
+
+
+ {messages.length === 0 ? (
+
{text.empty}
+ ) : (
+ messages.map((message) => (
+
+ {message.text}
+ {message.createdLabel === undefined ? null : }
+
+ ))
+ )}
+ {children}
+
+
+ {stateMessage === undefined ? null : (
+
+ {stateMessage}
+
+ )}
+
+
+
+
+ {text.analysis}
+
+
+ );
+}
diff --git a/apps/web/src/features/agent/agent-store.ts b/apps/web/src/features/agent/agent-store.ts
index 6a943544..4ce96395 100644
--- a/apps/web/src/features/agent/agent-store.ts
+++ b/apps/web/src/features/agent/agent-store.ts
@@ -7,23 +7,86 @@ export interface AgentConversationSummaryV1 {
export interface AgentStoreV1 {
readonly getActiveConversation: () => AgentConversationSummaryV1 | undefined;
+ readonly getConversations: () => readonly AgentConversationSummaryV1[];
+ readonly selectConversation: (conversationId: string) => void;
readonly setActiveConversation: (conversation: AgentConversationSummaryV1 | undefined) => void;
+ readonly setConversations: (conversations: readonly AgentConversationSummaryV1[]) => void;
readonly isOpen: () => boolean;
readonly setOpen: (open: boolean) => void;
+ readonly getSnapshot: () => AgentStoreSnapshotV1;
+ readonly subscribe: (listener: () => void) => () => void;
+}
+
+export interface AgentStoreSnapshotV1 {
+ readonly activeConversation?: AgentConversationSummaryV1;
+ readonly conversations: readonly AgentConversationSummaryV1[];
+ readonly open: boolean;
}
/** One agent store persists conversation context across destinations. */
export function createAgentStore(initial?: AgentConversationSummaryV1): AgentStoreV1 {
let active = initial;
+ let conversations: readonly AgentConversationSummaryV1[] = Object.freeze(
+ initial === undefined ? [] : [initial],
+ );
let open = false;
+ let snapshot: AgentStoreSnapshotV1 = Object.freeze({
+ ...(active === undefined ? {} : { activeConversation: active }),
+ conversations,
+ open,
+ });
+ const listeners = new Set<() => void>();
+ const emit = () => {
+ snapshot = Object.freeze({
+ ...(active === undefined ? {} : { activeConversation: active }),
+ conversations,
+ open,
+ });
+ for (const listener of listeners) listener();
+ };
return {
getActiveConversation: () => active,
+ getConversations: () => conversations,
+ selectConversation: (conversationId) => {
+ const next = conversations.find((conversation) => conversation.conversationId === conversationId);
+ if (next === undefined || next.conversationId === active?.conversationId) return;
+ active = next;
+ emit();
+ },
setActiveConversation: (conversation) => {
active = conversation;
+ if (conversation === undefined) {
+ conversations = Object.freeze([]);
+ } else {
+ conversations = Object.freeze([
+ conversation,
+ ...conversations.filter(
+ (candidate) => candidate.conversationId !== conversation.conversationId,
+ ),
+ ]);
+ }
+ emit();
+ },
+ setConversations: (nextConversations) => {
+ conversations = Object.freeze([...nextConversations]);
+ if (conversations.length === 0) active = undefined;
+ else {
+ active =
+ conversations.find(
+ (conversation) => conversation.conversationId === active?.conversationId,
+ ) ?? conversations[0];
+ }
+ emit();
},
isOpen: () => open,
setOpen: (next) => {
open = next;
+ emit();
+ },
+ getSnapshot: () => snapshot,
+ subscribe: (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
},
};
}
diff --git a/apps/web/src/features/agent/floating-agent-panel.tsx b/apps/web/src/features/agent/floating-agent-panel.tsx
index 2054b417..7d4059a8 100644
--- a/apps/web/src/features/agent/floating-agent-panel.tsx
+++ b/apps/web/src/features/agent/floating-agent-panel.tsx
@@ -1,3 +1,8 @@
+import brandMarkUrl from '@databreeze/design-tokens/brand/generated/web/favicon-32.png';
+import { useSyncExternalStore } from 'react';
+
+import { XIcon } from '../../components/icons.tsx';
+import { AgentChatShell } from './agent-chat-shell.tsx';
import type { AgentStoreV1 } from './agent-store.ts';
export function FloatingAgentPanel({
@@ -9,6 +14,7 @@ export function FloatingAgentPanel({
readonly locale: 'en' | 'vi-VN';
readonly surface: 'dashboard' | 'data' | 'analysis';
}) {
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
if (surface === 'analysis') {
return (
- {store.getActiveConversation() ? {store.getActiveConversation()?.title}
: null}
+ {snapshot.activeConversation ? {snapshot.activeConversation.title}
: null}
);
}
- if (!store.isOpen()) return null;
+ if (!snapshot.open) return null;
+ const active = snapshot.activeConversation;
+ const text =
+ locale === 'vi-VN'
+ ? {
+ close: 'Đóng trợ lý',
+ context: 'Đang dùng ngữ cảnh được cấp quyền',
+ continue: 'Mở trong Phân tích',
+ empty: 'Mở Phân tích để bắt đầu một hội thoại được gắn với dữ liệu của bạn.',
+ title: 'Trợ lý DataBreeze',
+ }
+ : {
+ close: 'Close agent',
+ context: 'Using authorized context',
+ continue: 'Open in Analysis',
+ empty: 'Open Analysis to start a conversation bound to your data.',
+ title: 'DataBreeze Agent',
+ };
return (
);
}
diff --git a/apps/web/src/features/analysis/analysis-route-page.tsx b/apps/web/src/features/analysis/analysis-route-page.tsx
index b48bcf9b..13f43755 100644
--- a/apps/web/src/features/analysis/analysis-route-page.tsx
+++ b/apps/web/src/features/analysis/analysis-route-page.tsx
@@ -170,11 +170,56 @@ function turnError(error: unknown): AnalysisTurnErrorV1 {
function DemoAnalysisRoutePage({ locale }: { readonly locale: 'en' | 'vi-VN' }) {
const [demoConversations, setDemoConversations] = useState(DEMO_CONVERSATIONS);
+ const [activeConversationId, setActiveConversationId] = useState(
+ DEMO_CONVERSATIONS[0]?.conversationId,
+ );
+
+ useEffect(() => {
+ workspaceAgentStore.setConversations(
+ demoConversations.map((conversation) => {
+ const context = conversation.datasetContext[0];
+ return {
+ conversationId: conversation.conversationId,
+ title: conversation.title,
+ datasetLabel:
+ context?.datasetLabel ?? (locale === 'vi-VN' ? 'Ngữ cảnh hiện tại' : 'Current context'),
+ datasetVersionLabel:
+ context?.datasetVersionLabel ??
+ (locale === 'vi-VN' ? 'Phiên bản hiện tại' : 'Current version'),
+ };
+ }),
+ );
+ if (activeConversationId !== undefined) {
+ workspaceAgentStore.selectConversation(activeConversationId);
+ }
+ }, [activeConversationId, demoConversations, locale]);
+
return (
{
+ const conversationId = crypto.randomUUID();
+ setDemoConversations((current) => [
+ {
+ conversationId,
+ title: locale === 'vi-VN' ? 'Phân tích mới' : 'New analysis',
+ updatedLabel: locale === 'vi-VN' ? 'Bây giờ' : 'Now',
+ datasetContext: [
+ {
+ datasetLabel: locale === 'vi-VN' ? 'Bán hàng toàn quốc' : 'National sales',
+ datasetVersionLabel: locale === 'vi-VN' ? 'Phiên bản 12' : 'Version 12',
+ },
+ ],
+ messages: [],
+ },
+ ...current,
+ ]);
+ setActiveConversationId(conversationId);
+ }}
+ onSelectConversation={setActiveConversationId}
onSendMessage={(message: string, conversationId?: string) => {
if (conversationId === undefined) return;
setDemoConversations((current) =>
@@ -245,23 +290,30 @@ function LiveAnalysisRoutePage({ locale }: { readonly locale: 'en' | 'vi-VN' })
useEffect(() => {
if (historyQuery.isError) {
- workspaceAgentStore.setActiveConversation(undefined);
+ workspaceAgentStore.setConversations([]);
return;
}
if (historyQuery.isSuccess && activeSummary === undefined) {
- workspaceAgentStore.setActiveConversation(undefined);
+ workspaceAgentStore.setConversations([]);
return;
}
if (activeSummary === undefined) return;
- const context = datasetContext(activeSummary, locale)[0];
- if (context === undefined) return;
- workspaceAgentStore.setActiveConversation({
- conversationId: activeSummary.conversationId,
- title: activeSummary.title,
- datasetLabel: context.datasetLabel,
- datasetVersionLabel: context.datasetVersionLabel,
- });
- }, [activeSummary, historyQuery.isError, historyQuery.isSuccess, locale]);
+ workspaceAgentStore.setConversations(
+ authorizedSummaries.map((summary) => {
+ const context = datasetContext(summary, locale)[0];
+ return {
+ conversationId: summary.conversationId,
+ title: summary.title,
+ datasetLabel:
+ context?.datasetLabel ?? (locale === 'vi-VN' ? 'Ngữ cảnh hiện tại' : 'Current context'),
+ datasetVersionLabel:
+ context?.datasetVersionLabel ??
+ (locale === 'vi-VN' ? 'Phiên bản hiện tại' : 'Current version'),
+ };
+ }),
+ );
+ workspaceAgentStore.selectConversation(activeSummary.conversationId);
+ }, [activeSummary, authorizedSummaries, historyQuery.isError, historyQuery.isSuccess, locale]);
const historyConversations = useMemo(
() => authorizedSummaries.map((item) => summaryPresentation(item, locale)),
diff --git a/apps/web/src/styles/workspace-shell.css b/apps/web/src/styles/workspace-shell.css
index a2a39e4e..3d9b7587 100644
--- a/apps/web/src/styles/workspace-shell.css
+++ b/apps/web/src/styles/workspace-shell.css
@@ -725,6 +725,197 @@
}
}
+.agent-chat-shell {
+ min-height: 0;
+ display: grid;
+ grid-template-rows: auto auto minmax(150px, 1fr) auto auto auto;
+ gap: 12px;
+}
+
+.agent-chat-shell__toolbar {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 10px;
+}
+
+.agent-chat-shell__toolbar label {
+ min-width: 0;
+ display: grid;
+ gap: 5px;
+ color: #6b7892;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.agent-chat-shell__toolbar select {
+ width: 100%;
+ min-height: 40px;
+ padding: 0 34px 0 11px;
+ border: 1px solid #d7e1ef;
+ border-radius: 10px;
+ color: #102a63;
+ background: #ffffff;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.agent-chat-shell__new,
+.agent-chat-shell__analysis-link {
+ min-height: 40px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 12px;
+ border: 1px solid #cbd9ef;
+ border-radius: 10px;
+ color: #075de8;
+ background: #ffffff;
+ font-size: 12px;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.agent-chat-shell__context {
+ margin: 0;
+ padding: 9px 11px;
+ border-inline-start: 3px solid #075de8;
+ color: #52617b;
+ background: #f4f7fc;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.agent-chat-shell__messages {
+ min-height: 150px;
+ max-height: min(44vh, 420px);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ padding: 12px;
+ border: 1px solid #e0e7f1;
+ border-radius: 14px;
+ background: #f8faff;
+}
+
+.agent-chat-shell__empty {
+ margin: auto;
+ color: #71809a;
+ font-size: 12px;
+ text-align: center;
+}
+
+.agent-chat-shell__message {
+ max-width: 88%;
+ padding: 10px 12px;
+ border: 1px solid #dbe5f2;
+ border-radius: 13px;
+ color: #1a2d53;
+ background: #ffffff;
+}
+
+.agent-chat-shell__message--user {
+ align-self: flex-end;
+ border-color: #075de8;
+ color: #ffffff;
+ background: #075de8;
+}
+
+.agent-chat-shell__message p,
+.agent-chat-shell__message time,
+.agent-chat-shell__state {
+ margin: 0;
+}
+
+.agent-chat-shell__message p {
+ font-size: 13px;
+ line-height: 1.55;
+}
+
+.agent-chat-shell__message time {
+ display: block;
+ margin-top: 5px;
+ color: inherit;
+ font-size: 10px;
+ opacity: 0.72;
+}
+
+.agent-chat-shell__state {
+ color: #53627e;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.agent-chat-shell__composer {
+ display: grid;
+ gap: 6px;
+}
+
+.agent-chat-shell__composer > label {
+ color: #52617b;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.agent-chat-shell__composer > div {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 8px;
+ padding: 8px;
+ border: 1px solid #ccd8ea;
+ border-radius: 14px;
+ background: #ffffff;
+}
+
+.agent-chat-shell__composer textarea {
+ width: 100%;
+ min-height: 58px;
+ resize: vertical;
+ padding: 7px;
+ border: 0;
+ color: #102a63;
+ background: transparent;
+ font: inherit;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.agent-chat-shell__composer textarea:focus {
+ outline: 0;
+}
+
+.agent-chat-shell__composer button {
+ min-width: 62px;
+ min-height: 38px;
+ padding: 0 12px;
+ border: 0;
+ border-radius: 10px;
+ color: #ffffff;
+ background: #075de8;
+ font-weight: 700;
+}
+
+.agent-chat-shell__composer button:disabled {
+ color: #8190aa;
+ background: #e7edf6;
+}
+
+.agent-chat-shell__analysis-link {
+ justify-self: start;
+ min-height: 34px;
+ border-color: transparent;
+ padding-inline: 0;
+ background: transparent;
+}
+
+.agent-chat-shell :is(button, select, textarea, a):focus-visible {
+ outline: 3px solid #9cbcff;
+ outline-offset: 2px;
+}
+
/* WEB-013/DDA-020: Dashboard uses the shared shell and the full pale-blue canvas. */
.app-shell.app-shell--dashboard {
min-height: 100vh;
diff --git a/apps/web/test/floating-agent.test.tsx b/apps/web/test/floating-agent.test.tsx
index 3bffba0f..4228e3d6 100644
--- a/apps/web/test/floating-agent.test.tsx
+++ b/apps/web/test/floating-agent.test.tsx
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { ApplicationBoundary, createAppRouter } from '../src/app/app.tsx';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
@@ -33,6 +34,71 @@ describe('floating agent surfaces', () => {
expect(screen.getByRole('button', { name: 'Mở trợ lý' })).toBeTruthy();
});
+ it('opens a contextual DataBreeze assistant card instead of an empty panel', async () => {
+ const user = userEvent.setup();
+ const store = createAgentStore();
+ store.setActiveConversation({
+ conversationId: 'conversation-1',
+ title: 'Doanh thu theo khu vực',
+ datasetLabel: 'Bán hàng toàn quốc',
+ datasetVersionLabel: 'Phiên bản 12',
+ });
+ render(
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Mở trợ lý' }));
+
+ expect(screen.getByRole('heading', { name: 'Trợ lý DataBreeze' })).toBeTruthy();
+ expect(screen.getByText('Bán hàng toàn quốc · Phiên bản 12')).toBeTruthy();
+ expect(
+ screen.getByRole('link', { name: 'Mở trong Phân tích' }).getAttribute('href'),
+ ).toBe('/vi-VN/analysis?conversation=conversation-1');
+ });
+
+ it('switches between authorized conversations and opens the same thread in Analysis', async () => {
+ const user = userEvent.setup();
+ const store = createAgentStore();
+ store.setConversations([
+ {
+ conversationId: 'conversation-sales',
+ title: 'Doanh thu theo khu vực',
+ datasetLabel: 'Bán hàng toàn quốc',
+ datasetVersionLabel: 'Phiên bản 12',
+ },
+ {
+ conversationId: 'conversation-orders',
+ title: 'Đơn hàng bất thường',
+ datasetLabel: 'Tồn kho cửa hàng',
+ datasetVersionLabel: 'Phiên bản 7',
+ },
+ ]);
+ render(
+
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Mở trợ lý' }));
+ await user.selectOptions(
+ screen.getByRole('combobox', { name: 'Chuyển hội thoại' }),
+ 'conversation-orders',
+ );
+
+ expect(store.getActiveConversation()?.conversationId).toBe('conversation-orders');
+ expect(screen.getByText('Tồn kho cửa hàng · Phiên bản 7')).toBeTruthy();
+ expect(screen.getByRole('link', { name: 'Mở trong Phân tích' }).getAttribute('href')).toBe(
+ '/vi-VN/analysis?conversation=conversation-orders',
+ );
+ expect(screen.getByRole('link', { name: 'Hội thoại mới' }).getAttribute('href')).toBe(
+ '/vi-VN/analysis?new=1',
+ );
+ });
+
it('does not render a second floating agent on analysis', async () => {
const router = createAppRouter({ initialEntries: ['/vi-VN/analysis'] });
render();
diff --git a/apps/web/test/workspace-agent-store.test.ts b/apps/web/test/workspace-agent-store.test.ts
index e632e269..860369b6 100644
--- a/apps/web/test/workspace-agent-store.test.ts
+++ b/apps/web/test/workspace-agent-store.test.ts
@@ -16,4 +16,33 @@ describe('workspace agent store [WEB-024, DDA-031]', () => {
expect(workspaceAgentStore.getActiveConversation()).toEqual(conversation);
workspaceAgentStore.setActiveConversation(undefined);
});
+
+ it('switches only among supplied authorized conversation summaries', () => {
+ const conversations = [
+ {
+ conversationId: 'conversation-sales',
+ title: 'Revenue review',
+ datasetLabel: 'Sales',
+ datasetVersionLabel: 'version 8',
+ },
+ {
+ conversationId: 'conversation-orders',
+ title: 'Order anomalies',
+ datasetLabel: 'Orders',
+ datasetVersionLabel: 'version 3',
+ },
+ ];
+
+ workspaceAgentStore.setConversations(conversations);
+ workspaceAgentStore.selectConversation('conversation-orders');
+ expect(workspaceAgentStore.getActiveConversation()?.conversationId).toBe(
+ 'conversation-orders',
+ );
+
+ workspaceAgentStore.selectConversation('conversation-hidden');
+ expect(workspaceAgentStore.getActiveConversation()?.conversationId).toBe(
+ 'conversation-orders',
+ );
+ workspaceAgentStore.setActiveConversation(undefined);
+ });
});
From 2001f26da6bf6368949d0cb8ede09cb757079db8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Acharn=C3=A9?=
Date: Fri, 14 Aug 2026 21:10:29 +0700
Subject: [PATCH 05/11] feat(web): govern dashboard agent chart insertion
---
.../src/features/agent/agent-chat-shell.tsx | 45 +--
apps/web/src/features/agent/agent-store.ts | 12 +-
.../features/agent/floating-agent-button.tsx | 14 +-
.../features/agent/floating-agent-panel.tsx | 1 +
.../features/analysis/analysis-route-page.tsx | 26 ++
.../dashboards/dashboard-agent-panel.tsx | 284 +++++++++---------
.../features/dashboards/dashboard-page.tsx | 128 +++++++-
apps/web/src/styles/dashboard-agent.css | 41 ++-
apps/web/test/dashboard-agent-panel.test.tsx | 55 +++-
apps/web/test/floating-agent.test.tsx | 44 ++-
10 files changed, 458 insertions(+), 192 deletions(-)
diff --git a/apps/web/src/features/agent/agent-chat-shell.tsx b/apps/web/src/features/agent/agent-chat-shell.tsx
index 6e4dc1f3..fb51c147 100644
--- a/apps/web/src/features/agent/agent-chat-shell.tsx
+++ b/apps/web/src/features/agent/agent-chat-shell.tsx
@@ -1,14 +1,7 @@
import { useState, type FormEvent, type ReactNode, type Ref } from 'react';
-import { Link } from 'react-router-dom';
+import { Link, useInRouterContext } from 'react-router-dom';
-import type { AgentConversationSummaryV1 } from './agent-store.ts';
-
-export interface AgentMessagePresentationV1 {
- readonly messageId: string;
- readonly role: 'USER' | 'ASSISTANT';
- readonly text: string;
- readonly createdLabel?: string;
-}
+import type { AgentConversationSummaryV1, AgentMessagePresentationV1 } from './agent-store.ts';
export interface AgentChatShellProperties {
readonly activeConversationId?: string;
@@ -49,6 +42,7 @@ export function AgentChatShell({
textareaRef,
}: AgentChatShellProperties) {
const [draft, setDraft] = useState('');
+ const inRouter = useInRouterContext();
const text =
locale === 'vi-VN'
? {
@@ -100,12 +94,21 @@ export function AgentChatShell({
{onCreateConversation === undefined ? (
-
- {text.newConversation}
-
+ inRouter ? (
+
+ {text.newConversation}
+
+ ) : (
+
+ {text.newConversation}
+
+ )
) : (
-
- {text.analysis}
-
+ {inRouter ? (
+
+ {text.analysis}
+
+ ) : (
+
+ {text.analysis}
+
+ )}
);
}
diff --git a/apps/web/src/features/agent/agent-store.ts b/apps/web/src/features/agent/agent-store.ts
index 4ce96395..2b68538f 100644
--- a/apps/web/src/features/agent/agent-store.ts
+++ b/apps/web/src/features/agent/agent-store.ts
@@ -1,8 +1,16 @@
+export interface AgentMessagePresentationV1 {
+ readonly messageId: string;
+ readonly role: 'USER' | 'ASSISTANT';
+ readonly text: string;
+ readonly createdLabel?: string;
+}
+
export interface AgentConversationSummaryV1 {
readonly conversationId: string;
readonly title: string;
readonly datasetLabel: string;
readonly datasetVersionLabel: string;
+ readonly messages?: readonly AgentMessagePresentationV1[];
}
export interface AgentStoreV1 {
@@ -48,7 +56,9 @@ export function createAgentStore(initial?: AgentConversationSummaryV1): AgentSto
getActiveConversation: () => active,
getConversations: () => conversations,
selectConversation: (conversationId) => {
- const next = conversations.find((conversation) => conversation.conversationId === conversationId);
+ const next = conversations.find(
+ (conversation) => conversation.conversationId === conversationId,
+ );
if (next === undefined || next.conversationId === active?.conversationId) return;
active = next;
emit();
diff --git a/apps/web/src/features/agent/floating-agent-button.tsx b/apps/web/src/features/agent/floating-agent-button.tsx
index 8a884c0b..d2a814eb 100644
--- a/apps/web/src/features/agent/floating-agent-button.tsx
+++ b/apps/web/src/features/agent/floating-agent-button.tsx
@@ -1,3 +1,6 @@
+import { useSyncExternalStore } from 'react';
+import brandMarkUrl from '@databreeze/design-tokens/brand/generated/web/favicon-32.png';
+
import type { AgentStoreV1 } from './agent-store.ts';
export function FloatingAgentButton({
@@ -7,9 +10,16 @@ export function FloatingAgentButton({
readonly store: AgentStoreV1;
readonly locale: 'en' | 'vi-VN';
}) {
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
return (
-
) : null}
- {tenant !== undefined ? : null}
+ {tenant !== undefined || demoMode ? (
+
+ ) : null}
{statusMessage !== null ? (
{statusMessage}
diff --git a/apps/web/src/features/data/data-route-page.tsx b/apps/web/src/features/data/data-route-page.tsx
index d4b95fc4..9f133233 100644
--- a/apps/web/src/features/data/data-route-page.tsx
+++ b/apps/web/src/features/data/data-route-page.tsx
@@ -134,13 +134,19 @@ const DEMO_DATASETS: readonly DatasetCardV1[] = Object.freeze([
function copy(locale: 'en' | 'vi-VN') {
return locale === 'vi-VN'
? {
+ description:
+ 'Quản lý bộ dữ liệu, tệp nguồn, phiên bản và các mục cần xem xét trong phạm vi được cấp quyền.',
loading: 'Đang tải dữ liệu được cấp quyền...',
error: 'Không thể tải dữ liệu được cấp quyền.',
+ heading: 'Dữ liệu',
retry: 'Thử lại',
}
: {
+ description:
+ 'Manage datasets, source files, versions, and review items within your authorized scope.',
loading: 'Loading authorized data...',
error: 'Authorized data could not be loaded.',
+ heading: 'Data',
retry: 'Try again',
};
}
@@ -189,12 +195,24 @@ export function DataRoutePage() {
return (
{state.status === 'loading' ? (
-
- {text.loading}
+
+
+ {text.heading}
+ {text.description}
+
+
+ {text.loading}
+
) : state.status === 'error' ? (
-
- {text.error}
+
+
+ {text.heading}
+ {text.description}
+
+
+ {text.error}
+
setRetryKey((current) => current + 1)} type="button">
{text.retry}
diff --git a/apps/web/src/features/data/data-workspace-page.tsx b/apps/web/src/features/data/data-workspace-page.tsx
index f7f79978..4f87de4f 100644
--- a/apps/web/src/features/data/data-workspace-page.tsx
+++ b/apps/web/src/features/data/data-workspace-page.tsx
@@ -11,12 +11,16 @@ function copy(locale: 'en' | 'vi-VN') {
return locale === 'vi-VN'
? {
datasets: 'Bộ dữ liệu',
+ description:
+ 'Quản lý bộ dữ liệu, tệp nguồn, phiên bản và các mục cần xem xét trong phạm vi được cấp quyền.',
heading: 'Dữ liệu',
review: 'Cần xem xét',
sources: 'Tệp nguồn',
}
: {
datasets: 'Datasets',
+ description:
+ 'Manage datasets, source files, versions, and review items within your authorized scope.',
heading: 'Data',
review: 'Needs review',
sources: 'Source files',
@@ -90,7 +94,10 @@ export function DataWorkspacePage({
return (
- {text.heading}
+
+
{text.heading}
+
{text.description}
+