diff --git a/docs/temp/chat_only_ui_plan.md b/docs/temp/chat_only_ui_plan.md new file mode 100644 index 00000000..0c143e95 --- /dev/null +++ b/docs/temp/chat_only_ui_plan.md @@ -0,0 +1,93 @@ +# Chat-Only UI Plan + +## Current Frontend Structure Summary + +- `frontend/src/App.tsx` defines all React routes under `BrowserRouter basename="/huf"`. +- Most workbench pages are wrapped in `UnifiedLayout`, which mounts `AppSidebar` and an optional header. +- `AppSidebar` contains normal product navigation and filters items through `PermissionsContext` capabilities. The existing Chat nav item points to `/chat`. +- Existing `/chat` and `/chat/:chatId` routes use `UnifiedLayout hideHeader`, so the app sidebar is still present even though the normal header is hidden. +- Auth is centralized in `UserContext`; `ProtectedRoute` only checks logged-in state. +- Logout is exposed through `NavUser`, but that component is sidebar-specific. +- Existing mobile detection uses `useIsMobile`; current chat page has a mobile overlay conversation list. + +## Existing Chat UI/API Summary + +- `ChatPageV2` renders a conversation/sidebar experience with `ChatListing` and `ChatWindowV2`. +- `ChatWindowV2` renders `ChatWindowHeader` and `ChatMessageList`, and closes the app sidebar through `useSidebar`. +- `ChatMessageList` is the best reuse point: it loads existing messages, handles socket/tool updates, renders markdown/artifacts through `ChatMessage`, shows loading/error states, and renders `ChatInput`. +- `ChatInput` sends via `streamChatApi.sendMessage`; it supports new and existing conversations, streaming fallback, audio transcription, loading state, and conversation-created callbacks. +- `chatApi.ts` handles conversation and message reads plus feedback/title updates. +- `agentApi.getAgentModels` already filters chat-enabled agents with `allow_chat = 1` and `disabled = 0`. + +## Exact Files To Change + +- `frontend/src/App.tsx` - add lazy route for standalone chat-only UI. +- `frontend/src/pages/ChatOnlyPage.tsx` - new dedicated route page. +- `frontend/src/components/chat-only/ChatOnlyLayout.tsx` - standalone product-like full-height shell. +- `frontend/src/components/chat-only/ChatHeader.tsx` - Huf identity plus user/logout menu. +- `frontend/src/components/chat-only/ChatAgentSelector.tsx` - mobile-friendly chat-agent picker. +- `frontend/src/components/chat/ChatMessageList.tsx` - pass optional new-conversation path into `ChatInput`. +- `frontend/src/components/chat/ChatInput.tsx` - accept optional new-conversation path builder for route reuse. +- `frontend/src/services/agentApi.ts` - add `getChatAgents`. +- `docs/temp/chat_only_ui_tracker.md` - keep implementation tracker current. +- `docs/temp/chat_only_ui_plan.md` - this plan. + +## Proposed Route + +Use `/ui/chat` and `/ui/chat/:chatId`, which becomes `/huf/ui/chat` in the deployed app because the router basename is `/huf`. + +Reason: `/chat` already exists as the workbench chat experience with conversation listing and app layout behavior. `/ui/chat` cleanly matches the user-preferred URL while avoiding disruption to existing routes. + +## Component Plan + +- `ChatOnlyPage` owns route params, selected agent, available-agent loading, and navigation. +- `ChatOnlyLayout` provides the standalone full-height shell without `UnifiedLayout` or sidebar. +- `ChatHeader` shows a compact Huf brand mark, current agent label when available, and a user menu with logout from `UserContext`. +- `ChatAgentSelector` shows no-agent, one-agent, and multi-agent selection states. One enabled chat agent is auto-selected by the page. +- Reuse `ChatMessageList` for message loading, markdown rendering, sending, typing/loading, and error display. + +## Mobile Behavior Plan + +- Use `h-[100svh]`/`min-h-0` flex layout so mobile browser chrome does not push the input away. +- Keep header compact and sticky at the top of the standalone shell. +- Let `ChatMessageList` own natural scroll and bottom input behavior. +- Use full-screen width on phones; constrain only on larger screens with a centered chat container. +- Agent selector uses large touch targets and avoids desktop side panels. + +## Permission/Access Assumptions + +- The chat-only route remains behind `ProtectedRoute` for authenticated users. +- Agents are exposed only if returned by Frappe through `getChatAgents`, filtered to `allow_chat = 1` and `disabled = 0`. +- If permissions hide all agents, the page shows "No chat access available". +- Existing backend permissions remain authoritative for conversation/message reads and sends. + +## Implementation Checklist + +- [x] Add route for chat-only UI. +- [x] Add standalone chat-only page/layout. +- [x] Reuse existing chat service/hooks where possible. +- [x] Add responsive CSS/Tailwind classes. +- [x] Add logo/logout in header. +- [x] Handle one-agent, multi-agent, and no-agent states. +- [x] Test desktop and mobile viewport. +- [x] Run build/lint/typecheck if available. + +## Risks/Unknowns + +- `ChatMessageList` currently reads the new-chat agent from the URL search param; `ChatOnlyPage` must keep `?agent=` in sync for new conversations. +- `ChatInput` has one hardcoded `/chat/new` path for model mismatch; this needs a small optional prop to preserve route isolation. +- Backend access may differ by role; local UI can filter only what the current session can list. +- Existing `UserContext` login redirect points back to `/huf`, not `/huf/ui/chat`; changing that globally is out of scope. + +## Testing Checklist + +- [x] Build passes. +- [ ] Existing app/sidebar routes still render through existing layout. +- [ ] `/huf/ui/chat` renders without sidebar. +- [ ] One chat-enabled agent opens directly. +- [ ] Multiple chat-enabled agents show a mobile-friendly selector. +- [ ] No chat-enabled agents show a clean empty state. +- [ ] Logout remains available. +- [x] Mobile message list scrolls and input remains reachable. + +Note: browser viewport testing was skipped per user request after build verification. diff --git a/docs/temp/chat_only_ui_tracker.md b/docs/temp/chat_only_ui_tracker.md new file mode 100644 index 00000000..943b3770 --- /dev/null +++ b/docs/temp/chat_only_ui_tracker.md @@ -0,0 +1,82 @@ +# Chat-Only UI Tracker + +## Objective + +Build a dedicated standalone chat experience for users who only have access to conversational agents, reachable from a clean chat route without the normal admin/workbench sidebar. The page should reuse existing Huf chat APIs and auth/logout behavior, support one-agent, multi-agent, and no-agent states, and work well on mobile. + +## Current Understanding + +- Started from `origin/develop` on branch `feat_chat_ui`. +- Local `develop` had diverged from `origin/develop`, so the feature branch was created directly from `origin/develop` to use latest `tridz-dev/huf` code without rewriting local history. +- Routing lives in `frontend/src/App.tsx` under `BrowserRouter basename="/huf"`. +- Normal pages use `UnifiedLayout`, which always mounts `AppSidebar`; the existing `/chat` route uses `UnifiedLayout hideHeader`, so it still has app sidebar mechanics. +- Current chat page is `frontend/src/pages/ChatPageV2.tsx`. It renders `ChatListing` plus `ChatWindowV2`. +- `ChatWindowV2` calls `useSidebar().setOpen(false)`, so it depends on `SidebarProvider` from `UnifiedLayout`. +- Chat messages/input are mostly reusable through `ChatMessageList`, `ChatInput`, and the streaming service. `ChatMessageList` accepts an explicit `chatId` prop and uses the `agent` search param for new chats. +- Existing chat services are in `frontend/src/services/chatApi.ts`; message sending uses `frontend/src/services/streamChatApi.ts`. +- Existing allowed-chat agent selector is `AgentModelSelector`, backed by `getAgentModels` in `agentApi.ts`, which filters `allow_chat = 1` and `disabled = 0`. +- Auth/logout lives in `UserContext`; `NavUser` uses the same `logout` and `user` values but is coupled to sidebar UI. +- Permission context exposes `chat.use`, but route-level capability checks are not currently enforced by `ProtectedRoute`; sidebar visibility uses capabilities. +- Added route-level chat-only redirect guard: users whose only capability is `chat.use` are redirected from full app routes to `/ui/chat`, while `/ui/chat*` and `/view/*` remain accessible. +- Mobile pattern: `useIsMobile` is used in `ChatPageV2`; existing chat already prioritizes full-height flex layout and scrollable message list. + +## Decisions + +- Route: `/ui/chat` and `/ui/chat/:chatId` inside the `/huf` basename, yielding `/huf/ui/chat`. +- Layout approach: route-level standalone page outside `UnifiedLayout` so no app sidebar is mounted. +- Components to reuse: `ChatMessageList`, `ChatInput`, `ChatMessage`, markdown/artifact rendering, existing `streamChatApi`, existing `chatApi`, existing `UserContext`. +- Components to create: `ChatOnlyPage`, `ChatOnlyLayout`, `ChatHeader`, and `ChatAgentSelector`. +- API addition: add `getChatAgents` to `agentApi.ts` for a simple list of enabled chat agents using the same `allow_chat = 1` and `disabled = 0` criteria. +- Assumption: if the user can read an enabled `allow_chat` agent returned by Frappe permissions, it is valid to expose in chat-only selector. + +## TODO Checklist + +- [x] Switch to latest `tridz-dev/huf` code base safely. +- [x] Create `feat_chat_ui` branch from `origin/develop`. +- [x] Create tracker file. +- [x] Create planning document. +- [x] Inspect frontend pages, components, contexts, services, layout, navigation, chat, routing, auth/logout, and mobile patterns. +- [x] Update tracker with inspection notes. +- [x] Complete planning document before implementation. +- [x] Implement chat-only route. +- [x] Implement standalone chat-only page/layout/header/agent selector. +- [x] Reuse existing chat service/components where practical. +- [x] Handle one-agent, multi-agent, and no-agent states. +- [x] Verify desktop and mobile layout. +- [x] Run build/lint/typecheck where available. +- [ ] Commit, push, and open PR. +- [x] Add permission redirect behavior for chat-only users. +- [x] Update tracker and plan to final state. + +## Scratchpad + +- Existing `/chat` should remain unchanged for admin/workbench use. +- Existing `/chat` now redirects to `/ui/chat` only for users whose capability list is exactly `["chat.use"]`; broader users keep the desktop/full-app chat route. +- Need avoid using `ChatWindowV2` in standalone route because it requires `useSidebar`. +- `ChatMessageList` can be reused directly if standalone page controls selected/new agent. +- Added `getNewConversationPath` optional prop through `ChatMessageList` to `ChatInput` to avoid hardcoded `/chat/new` when used by `/ui/chat`. +- User said browser testing is not needed, so skipped desktop/mobile visual smoke test. + +## Files Changed + +- `docs/temp/chat_only_ui_tracker.md` - persistent task tracker, TODO list, and scratchpad. +- `docs/temp/chat_only_ui_plan.md` - temporary implementation plan. +- `frontend/src/App.tsx` - adds `/ui/chat` and `/ui/chat/:chatId` protected routes. +- `frontend/src/App.tsx` - also redirects chat-only users away from full app routes. +- `frontend/src/pages/ChatOnlyPage.tsx` - standalone chat-only page state and routing. +- `frontend/src/components/chat-only/ChatOnlyLayout.tsx` - standalone full-height shell without sidebar. +- `frontend/src/components/chat-only/ChatHeader.tsx` - Huf identity and user/logout menu. +- `frontend/src/components/chat-only/ChatAgentSelector.tsx` - mobile-friendly chat agent selection and empty states. +- `frontend/src/components/chat/ChatMessageList.tsx` - passes route-safe new conversation path to input. +- `frontend/src/components/chat/ChatInput.tsx` - accepts optional new conversation route builder. +- `frontend/src/services/agentApi.ts` - adds `getChatAgents` filtered to enabled chat agents. + +## Testing Log + +- `yarn typecheck` in `frontend` - initially failed because declared dependencies `media-chrome` and `prismjs` were missing from `node_modules`. +- `yarn build` in `frontend` - initially failed for the same missing dependencies. +- `yarn lint` in `frontend` - failed on existing repo-wide lint issues (hundreds of pre-existing `any`/unused-vars/hook warnings across unrelated files). +- `yarn install --frozen-lockfile` in `frontend` - completed; no yarn lockfile exists, dependencies were installed from `package.json`. +- `yarn build` in `frontend` - sandbox run reached Vite output but failed clearing `huf/public/frontend/assets` with `EPERM`. +- `yarn build` in `frontend` with elevated filesystem access - passed. Vite emitted existing large chunk warnings only. +- Browser/mobile smoke test - skipped per user request. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cf50f2c3..87d653a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Navigate, Routes, Route, useLocation } from 'react-router-dom'; import { UserProvider } from './contexts/UserContext'; -import { PermissionsProvider } from './contexts/PermissionsContext'; +import { PermissionsProvider, usePermissions } from './contexts/PermissionsContext'; import { ProtectedRoute } from './components/ProtectedRoute'; import { AuthenticatingPage } from './components/AuthenticatingPage'; import { FlowProvider } from './contexts/FlowContext'; @@ -31,6 +31,7 @@ const FlowCanvasPageWrapper = lazy(() => import('./pages/FlowCanvasPageWrapper') const DataPage = lazy(() => import('./pages/DataPage')); const IntegrationsPageWrapper = lazy(() => import('./pages/IntegrationsPageWrapper')); const ChatPage = lazy(() => import('./pages/ChatPageV2')); +const ChatOnlyPage = lazy(() => import('./pages/ChatOnlyPage')); const Executions = lazy(() => import('./pages/Executions')); const AgentRunDetailPage = lazy(() => import('./pages/AgentRunDetailPage')); const McpDetailsPageWrapper = lazy(() => import('./pages/McpDetailsPageWrapper')); @@ -51,6 +52,21 @@ import { const UsersPage = lazy(() => import('./pages/UsersPage')); const RolesPage = lazy(() => import('./pages/RolesPage')); +function ChatOnlyRedirectGuard() { + const location = useLocation(); + const { capabilities, isLoading } = usePermissions(); + const isChatOnlyUser = + capabilities.includes('chat.use') && capabilities.every((capability) => capability === 'chat.use'); + const isAllowedChatOnlyPath = + location.pathname.startsWith('/ui/chat') || location.pathname.startsWith('/view/'); + + if (isLoading || !isChatOnlyUser || isAllowedChatOnlyPath) { + return null; + } + + return ; +} + function App() { useEffect(() => { const connectionDescription = @@ -131,6 +147,7 @@ function App() { + }> } /> + + }> + + + + } + /> + + }> + + + + } + /> void; +} + +export function ChatAgentSelector({ + agents, + loading, + error, + onSelectAgent, +}: ChatAgentSelectorProps) { + if (loading) { + return ( +
+
+ + + +
+ + + +
+
+
+ ); + } + + if (error) { + return ( + } + title="Chat is unavailable" + description={error} + /> + ); + } + + if (agents.length === 0) { + return ( + } + title="No chat access available" + description="There are no enabled chat agents available for your account." + /> + ); + } + + return ( +
+
+
+
+ +
+

Choose an assistant

+

Start a focused chat with one of your available Huf agents.

+
+ +
+ {agents.map((agent) => ( + + ))} +
+
+
+ ); +} + +function CenteredState({ + icon, + title, + description, +}: { + icon: ReactNode; + title: string; + description: string; +}) { + return ( +
+
+
+ {icon} +
+

{title}

+

{description}

+
+
+ ); +} diff --git a/frontend/src/components/chat-only/ChatHeader.tsx b/frontend/src/components/chat-only/ChatHeader.tsx new file mode 100644 index 00000000..fe04f240 --- /dev/null +++ b/frontend/src/components/chat-only/ChatHeader.tsx @@ -0,0 +1,78 @@ +import { ChevronsUpDown, LogOut, Zap } from "lucide-react"; +import { useUser } from "@/contexts/UserContext"; +import UserAvatar from "@/components/UserAvatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +interface ChatHeaderProps { + agentLabel?: string; +} + +export function ChatHeader({ agentLabel }: ChatHeaderProps) { + const { logout, user } = useUser(); + const displayName = user?.full_name || user?.name || "User"; + const displayEmail = user?.email || ""; + + return ( +
+
+
+
+ +
+
+
+ HufAI + {agentLabel && ( + + {agentLabel} + + )} +
+

Chat

+
+
+ + {user && ( + + + + + + +
+ +
+

{displayName}

+ {displayEmail && ( +

{displayEmail}

+ )} +
+
+
+ + + + Log out + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/chat-only/ChatOnlyLayout.tsx b/frontend/src/components/chat-only/ChatOnlyLayout.tsx new file mode 100644 index 00000000..2f9cbc52 --- /dev/null +++ b/frontend/src/components/chat-only/ChatOnlyLayout.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from "react"; +import { ChatHeader } from "./ChatHeader"; + +interface ChatOnlyLayoutProps { + agentLabel?: string; + children: ReactNode; +} + +export function ChatOnlyLayout({ agentLabel, children }: ChatOnlyLayoutProps) { + return ( +
+ +
+
+ {children} +
+
+
+ ); +} diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx index d1e1bfae..e2e4af6b 100644 --- a/frontend/src/components/chat/ChatInput.tsx +++ b/frontend/src/components/chat/ChatInput.tsx @@ -20,6 +20,7 @@ interface ChatInputProps { chatId: string | null; agentName: string; onConversationCreated?: (conversationId: string, agentName?: string) => void; + getNewConversationPath?: (agentName: string) => string; onStatusChange: (status: 'submitted' | 'streaming' | 'ready' | 'error') => void; onLoadingTypeChange?: (type: LoadingType) => void; isCreatingConversationRef: React.MutableRefObject; @@ -33,6 +34,7 @@ export function ChatInput({ chatId, agentName, onConversationCreated, + getNewConversationPath, onStatusChange, onLoadingTypeChange, isCreatingConversationRef, @@ -328,9 +330,9 @@ export function ChatInput({ const handleNewConversation = useCallback(() => { if (agentName) { - navigate(`/chat/new?agent=${agentName}`); + navigate(getNewConversationPath?.(agentName) ?? `/chat/new?agent=${agentName}`); } - }, [navigate, agentName]); + }, [navigate, agentName, getNewConversationPath]); if (!agentName) { return null; diff --git a/frontend/src/components/chat/ChatMessageList.tsx b/frontend/src/components/chat/ChatMessageList.tsx index f4dfa364..6a9039b0 100644 --- a/frontend/src/components/chat/ChatMessageList.tsx +++ b/frontend/src/components/chat/ChatMessageList.tsx @@ -21,11 +21,13 @@ import { interface ChatMessageListProps { chatId?: string | null; onConversationCreated?: (conversationId: string, agentName?: string) => void; + getNewConversationPath?: (agentName: string) => string; } export function ChatMessageList({ chatId: chatIdProp, - onConversationCreated + onConversationCreated, + getNewConversationPath, }: ChatMessageListProps) { const { chatId: routeChatId } = useParams<{ chatId?: string }>(); const [searchParams] = useSearchParams(); @@ -321,6 +323,7 @@ export function ChatMessageList({ chatId={chatId} agentName={agentName} onConversationCreated={onConversationCreated} + getNewConversationPath={getNewConversationPath} onStatusChange={setStatus} onLoadingTypeChange={setLoadingType} isCreatingConversationRef={isCreatingConversationRef} diff --git a/frontend/src/pages/ChatOnlyPage.tsx b/frontend/src/pages/ChatOnlyPage.tsx new file mode 100644 index 00000000..1ba19774 --- /dev/null +++ b/frontend/src/pages/ChatOnlyPage.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { ChatMessageList } from "@/components/chat/ChatMessageList"; +import { ChatAgentSelector } from "@/components/chat-only/ChatAgentSelector"; +import { ChatOnlyLayout } from "@/components/chat-only/ChatOnlyLayout"; +import { getChatAgents, type ChatAgentItem } from "@/services/agentApi"; + +export default function ChatOnlyPage() { + const navigate = useNavigate(); + const { chatId: routeChatId } = useParams<{ chatId?: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + const chatId = routeChatId && routeChatId !== "new" ? routeChatId : null; + const selectedAgent = searchParams.get("agent") || ""; + + const [agents, setAgents] = useState([]); + const [loadingAgents, setLoadingAgents] = useState(true); + const [agentsError, setAgentsError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function loadAgents() { + setLoadingAgents(true); + setAgentsError(null); + try { + const nextAgents = await getChatAgents(); + if (!cancelled) { + setAgents(nextAgents); + } + } catch (error) { + if (!cancelled) { + setAgentsError(error instanceof Error ? error.message : "Unable to load chat agents."); + } + } finally { + if (!cancelled) { + setLoadingAgents(false); + } + } + } + + loadAgents(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (loadingAgents || chatId || selectedAgent || agents.length !== 1) { + return; + } + + setSearchParams({ agent: agents[0].name }, { replace: true }); + }, [agents, chatId, loadingAgents, selectedAgent, setSearchParams]); + + const currentAgent = useMemo( + () => agents.find((agent) => agent.name === selectedAgent), + [agents, selectedAgent] + ); + + const handleSelectAgent = useCallback( + (agentName: string) => { + navigate(`/ui/chat?agent=${encodeURIComponent(agentName)}`); + }, + [navigate] + ); + + const handleConversationCreated = useCallback( + (conversationId: string) => { + navigate(`/ui/chat/${conversationId}`); + }, + [navigate] + ); + + const getNewConversationPath = useCallback( + (agentName: string) => `/ui/chat?agent=${encodeURIComponent(agentName)}`, + [] + ); + + const shouldShowSelector = !chatId && (!selectedAgent || (!loadingAgents && !currentAgent)); + + return ( + + {shouldShowSelector ? ( + + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/services/agentApi.ts b/frontend/src/services/agentApi.ts index d8b18943..7abe21d7 100644 --- a/frontend/src/services/agentApi.ts +++ b/frontend/src/services/agentApi.ts @@ -49,6 +49,16 @@ const AGENT_MODEL_FIELDS = [ 'slug', ]; +const CHAT_AGENT_FIELDS = [ + 'name', + 'agent_name', + 'description', + 'model', + 'chef', + 'slug', + 'agent_color', +]; + /** * Fields needed for agent triggers listing */ @@ -107,6 +117,16 @@ export interface PaginatedAgentsResponse { total?: number; } +export interface ChatAgentItem { + name: string; + agent_name: string; + description?: string | null; + model?: string | null; + chef?: string | null; + slug?: string | null; + agent_color?: string | null; +} + /** * Fetch agents from Frappe * Supports pagination, search, and filtering @@ -169,6 +189,25 @@ export async function getAgents( } } +export async function getChatAgents(): Promise { + try { + const agents = await db.getDocList(doctype.Agent, { + fields: CHAT_AGENT_FIELDS, + filters: [ + ['allow_chat', '=', 1], + ['disabled', '=', 0], + ], + limit: 1000, + orderBy: { field: 'modified', order: 'desc' }, + }); + + return agents as ChatAgentItem[]; + } catch (error) { + handleFrappeError(error, 'Error fetching chat agents'); + return []; + } +} + /** * Fetch a single agent by name * Fetches all fields for detail view @@ -474,4 +513,3 @@ export async function getAgentModels( }; } } -