diff --git a/.gitignore b/.gitignore index 6328d860..0a952d55 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ runtime/ uploads/ artifacts/ reports/ +/marketing-materials/ *.db *.sqlite *.sqlite3 diff --git a/apps/web/README.md b/apps/web/README.md index 8e10eb78..1cc8ddb4 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -39,6 +39,21 @@ corepack pnpm web:browser:install:ci corepack pnpm web:test:e2e:ci ``` +For the everyday edit-refresh loop, run the split local stack from the +repository root: + +```text +corepack pnpm dev:infra # Docker: PostgreSQL, Redis, MinIO, Mailpit, OTEL +corepack pnpm dev:api # watched API on http://127.0.0.1:3000 +corepack pnpm dev:web # Vite + React Refresh on http://127.0.0.1:5173 +``` + +Open `http://127.0.0.1:5173/vi-VN/workspace`. Vite proxies `/v1`, `/v3`, and +`/health` to the API, so editing `apps/web/src/*` updates the browser without +rebuilding a container. Do not use the pilot/production Caddy URL for this +loop; it serves a built bundle and has no HMR. `dev:stack` prints the same +three-terminal instructions without leaving background processes behind. + The root `web:test:e2e` command builds public workspace dependencies first, runs the production preview desktop/mobile suite, and then starts the Vite development server for its browser regression. `web:test:e2e:preview` and `web:test:e2e:dev` expose those lanes independently. Local diff --git a/apps/web/src/components/application-rail.tsx b/apps/web/src/components/application-rail.tsx index 1752a690..9b4bfe1f 100644 --- a/apps/web/src/components/application-rail.tsx +++ b/apps/web/src/components/application-rail.tsx @@ -1,18 +1,26 @@ import wordmarkUrl from '@databreeze/design-tokens/brand/generated/web/navigation-wordmark-blue-204x50.png'; +import brandMarkUrl from '@databreeze/design-tokens/brand/generated/web/install-icon-192.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 +64,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 +98,64 @@ 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..c0eb982d 100644 --- a/apps/web/src/components/shell-layout.tsx +++ b/apps/web/src/components/shell-layout.tsx @@ -2,48 +2,59 @@ 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(() => - typeof globalThis.matchMedia === 'function' - ? globalThis.matchMedia(MOBILE_QUERY).matches - : false, +function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => + typeof globalThis.matchMedia === 'function' ? 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 +65,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/features/agent/agent-chat-shell.tsx b/apps/web/src/features/agent/agent-chat-shell.tsx new file mode 100644 index 00000000..0b3e6613 --- /dev/null +++ b/apps/web/src/features/agent/agent-chat-shell.tsx @@ -0,0 +1,186 @@ +import { useId, useState, type FormEvent, type ReactNode, type Ref } from 'react'; +import { Link, useInRouterContext } from 'react-router-dom'; + +import type { AgentConversationSummaryV1, AgentMessagePresentationV1 } from './agent-store.ts'; + +function newConversationPath(analysisHref: string, explicitHref?: string): string { + if (explicitHref !== undefined) return explicitHref; + return `${analysisHref.split('?')[0] ?? analysisHref}?new=1`; +} + +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 composerId = useId(); + const inRouter = useInRouterContext(); + const createHref = newConversationPath(analysisHref, newConversationHref); + 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; + try { + await onSubmitMessage(message); + setDraft(''); + } catch { + // The caller owns localized failure copy. Preserve the draft for retry. + } + } + + return ( +
+
+ + {onCreateConversation === undefined ? ( + inRouter ? ( + + {text.newConversation} + + ) : ( + + {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} +

+ )} + +
void submit(event)}> + +
+