From 82c5f07f7ad265367b4831d0e6f47b5139ff547b Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Wed, 25 Mar 2026 18:38:04 -0500 Subject: [PATCH] improved chat ui --- src/App.tsx | 27 ++++- src/components/AIHelpPanel.tsx | 172 ++++++++++++----------------- src/components/Layout.tsx | 2 + src/components/MarkdownMessage.tsx | 111 +++++++++++++++++++ src/components/Navigation.tsx | 49 ++++---- src/lib/ai-help.ts | 112 +++++++++++++++++++ src/store/preferences.ts | 13 +++ src/store/state.ts | 4 + 8 files changed, 363 insertions(+), 127 deletions(-) create mode 100644 src/components/MarkdownMessage.tsx create mode 100644 src/lib/ai-help.ts diff --git a/src/App.tsx b/src/App.tsx index 76069a3..932988e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,8 @@ import { EmbedAuthWrapper } from './components/EmbedAuthWrapper'; import { CriticalView } from './views/CriticalView'; import { AuthCallback } from './components/Auth'; import { pb } from './lib/pocketbase'; -import { initStore, setStore } from './store'; +import { initStore, setStore, store, appendAIHelpTipHistory } from './store'; +import { generateAIHelpTip, hasFireworksApiKey } from './lib/ai-help'; // const CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView }))); const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView }))); @@ -27,6 +28,8 @@ const App: Component = () => { const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid); const [location, setLocation] = createSignal(window.location.href); const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches); + const [aiHelpTip, setAiHelpTip] = createSignal(null); + const [hasRequestedSessionTip, setHasRequestedSessionTip] = createSignal(false); createEffect(() => { const view = currentView(); @@ -39,6 +42,25 @@ const App: Component = () => { currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView() ); + createEffect(() => { + const ready = isAuthenticated() && isDesktop() && !store.isInitializing; + if (!ready) return; + if (!hasFireworksApiKey()) return; + if (hasRequestedSessionTip()) return; + + setHasRequestedSessionTip(true); + void (async () => { + try { + const nextTip = await generateAIHelpTip(store.aiHelpTipHistory); + if (!nextTip) return; + setAiHelpTip(nextTip); + await appendAIHelpTipHistory(nextTip); + } catch (err) { + console.error("Failed to generate AI help tip", err); + } + })(); + }); + onMount(() => { const mediaQuery = window.matchMedia("(min-width: 768px)"); const openHelpGuide = (sectionId?: string) => { @@ -81,6 +103,8 @@ const App: Component = () => { const removeListener = pb.authStore.onChange((token) => { const wasAuthenticated = isAuthenticated(); setIsAuthenticated(!!token); + setHasRequestedSessionTip(false); + setAiHelpTip(null); if (token && (isFirstRun || !wasAuthenticated)) { initStore(); @@ -155,6 +179,7 @@ const App: Component = () => { setView={setCurrentView} isAIHelpOpen={currentView() === "help_ai" && isDesktop()} onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")} + aiHelpTip={aiHelpTip()} hideQuickEntry={currentView() === "help_ai" && !isDesktop()} >
}> diff --git a/src/components/AIHelpPanel.tsx b/src/components/AIHelpPanel.tsx index dffa3db..3c560b7 100644 --- a/src/components/AIHelpPanel.tsx +++ b/src/components/AIHelpPanel.tsx @@ -1,30 +1,16 @@ import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"; -import { Bot, LoaderCircle, RefreshCw, Send } from "lucide-solid"; +import { MessageCircle, LoaderCircle, RefreshCw, Send } from "lucide-solid"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config"; -import helpGuide from "../../docs/HELP_GUIDE.md?raw"; +import { FIREWORKS_HELP_MODEL } from "@/lib/app-config"; +import { askAIHelp, hasFireworksApiKey } from "@/lib/ai-help"; +import { MarkdownMessage } from "./MarkdownMessage"; type ChatMessage = { role: "user" | "assistant"; content: string; }; -const SYSTEM_PROMPT = `You are the TasGrid AI Help assistant. - -You answer questions about TasGrid using the reference guide below. - -You must follow this instruction exactly: "use only this guide for all information and never assume you know something that isn't mentioned in the guide" - -If the guide does not contain the answer, say that the guide does not mention it. -Do not use outside product knowledge. -Do not invent buttons, workflows, settings, or behaviors. -Prefer concise, practical answers. -When helpful, mention the relevant section name from the guide. - -TASGRID REFERENCE GUIDE -${helpGuide}`; - const SUGGESTED_PROMPTS = [ "How does sharing work in TasGrid?", "What is the difference between Handoff and Collaborative?", @@ -33,7 +19,7 @@ const SUGGESTED_PROMPTS = [ "How do recurrence and templates behave?" ]; -const INITIAL_MESSAGE = "Ask about TasGrid features, workflows, or edge cases. I only answer from the reference guide, so if the guide does not mention something, I will tell you."; +const INITIAL_MESSAGE = "Ask a question about how TasGrid works."; export const AIHelpPanel: Component<{ variant?: "page" | "sidebar"; @@ -48,7 +34,10 @@ export const AIHelpPanel: Component<{ const [error, setError] = createSignal(null); let messagesViewport: HTMLDivElement | undefined; - const hasApiKey = createMemo(() => FIREWORKS_API_KEY.trim().length > 0); + const hasApiKey = createMemo(() => hasFireworksApiKey()); + const showSuggestions = createMemo(() => + hasApiKey() && messages().length === 1 && messages()[0]?.role === "assistant" + ); createEffect(() => { messages(); @@ -79,7 +68,7 @@ export const AIHelpPanel: Component<{ ...nextMessages, { role: "assistant", - content: "AI Help is not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable Fireworks responses." + content: "Questions are not configured yet. Add `VITE_FIREWORKS_API_KEY` to the app environment to enable responses." } ]); setIsLoading(false); @@ -87,39 +76,7 @@ export const AIHelpPanel: Component<{ } try { - const response = await fetch(FIREWORKS_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${FIREWORKS_API_KEY}` - }, - body: JSON.stringify({ - model: FIREWORKS_HELP_MODEL, - reasoning_effort: "low", - temperature: 0.2, - max_tokens: 700, - messages: [ - { role: "system", content: SYSTEM_PROMPT }, - ...nextMessages.map(message => ({ - role: message.role, - content: message.content - })) - ] - }) - }); - - if (!response.ok) { - const bodyText = await response.text(); - throw new Error(bodyText || `Request failed with status ${response.status}`); - } - - const data = await response.json(); - const assistantText = data?.choices?.[0]?.message?.content?.trim(); - - if (!assistantText) { - throw new Error("The help assistant did not return any text."); - } - + const assistantText = await askAIHelp(nextMessages); setMessages(prev => [...prev, { role: "assistant", content: assistantText }]); } catch (err) { const message = err instanceof Error ? err.message : "Unknown error"; @@ -159,8 +116,8 @@ export const AIHelpPanel: Component<{ isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5" )}>
- -

AI Help

+ +

Ask Questions

- )} - + +
+ Add `VITE_FIREWORKS_API_KEY` to enable Ask Questions. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
@@ -215,24 +152,51 @@ export const AIHelpPanel: Component<{ > {(message) => ( -
-
-
- You}> - - Guide Assistant - +
+
+
+
+ You}> + + TasGrid Help + +
+
-
{message.content}
+ +
+ + {(prompt) => ( + + )} + +
+
)} @@ -241,15 +205,15 @@ export const AIHelpPanel: Component<{
- - Guide Assistant + + TasGrid Help
- Looking through the guide... + Finding an answer...
@@ -282,7 +246,7 @@ export const AIHelpPanel: Component<{ placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..." class={cn( "flex w-full rounded-xl border border-input bg-background transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none leading-relaxed", - isSidebar() ? "min-h-[88px] px-3 py-2.5 text-[0.75rem]" : "min-h-[96px] px-3 py-2.5 text-sm" + isSidebar() ? "min-h-[88px] px-3 py-2.5 text-sm" : "min-h-[96px] px-3 py-2.5 text-sm" )} />
- Ask AI Help + Ask Question
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index ecfd8b3..04a928c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -17,6 +17,7 @@ interface LayoutProps { setView: (v: string) => void; isAIHelpOpen?: boolean; onToggleAIHelp?: () => void; + aiHelpTip?: string | null; hideQuickEntry?: boolean; } @@ -116,6 +117,7 @@ export const Layout: Component = (props) => { setView={props.setView} isAIHelpOpen={!!props.isAIHelpOpen} onToggleAIHelp={() => props.onToggleAIHelp?.()} + aiHelpTip={props.aiHelpTip} isLocked={isSidebarLocked()} setIsLocked={setIsSidebarLocked} isPeeking={isSidebarPeeking()} diff --git a/src/components/MarkdownMessage.tsx b/src/components/MarkdownMessage.tsx new file mode 100644 index 0000000..deb1fc5 --- /dev/null +++ b/src/components/MarkdownMessage.tsx @@ -0,0 +1,111 @@ +import { type Component, createMemo } from "solid-js"; +import { cn } from "@/lib/utils"; + +const escapeHtml = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const applyInlineMarkdown = (value: string) => { + let text = escapeHtml(value); + + text = text.replace(/`([^`]+)`/g, "$1"); + text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); + text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); + text = text.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?:;]|$)/g, '$1$2'); + + return text; +}; + +const renderMarkdown = (markdown: string) => { + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const html: string[] = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + i += 1; + continue; + } + + if (trimmed.startsWith("```")) { + const codeLines: string[] = []; + i += 1; + while (i < lines.length && !lines[i].trim().startsWith("```")) { + codeLines.push(lines[i]); + i += 1; + } + if (i < lines.length) i += 1; + html.push(`
${escapeHtml(codeLines.join("\n"))}
`); + continue; + } + + if (/^[-*]\s+/.test(trimmed)) { + const items: string[] = []; + while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) { + items.push(`
  • ${applyInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}
  • `); + i += 1; + } + html.push(`
      ${items.join("")}
    `); + continue; + } + + if (/^\d+\.\s+/.test(trimmed)) { + const items: string[] = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) { + items.push(`
  • ${applyInlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}
  • `); + i += 1; + } + html.push(`
      ${items.join("")}
    `); + continue; + } + + if (/^>\s?/.test(trimmed)) { + const quoteLines: string[] = []; + while (i < lines.length && /^>\s?/.test(lines[i].trim())) { + quoteLines.push(applyInlineMarkdown(lines[i].trim().replace(/^>\s?/, ""))); + i += 1; + } + html.push(`

    ${quoteLines.join("
    ")}

    `); + continue; + } + + const paragraphLines: string[] = []; + while (i < lines.length && lines[i].trim() && !lines[i].trim().startsWith("```") && !/^[-*]\s+/.test(lines[i].trim()) && !/^\d+\.\s+/.test(lines[i].trim()) && !/^>\s?/.test(lines[i].trim())) { + paragraphLines.push(applyInlineMarkdown(lines[i].trim())); + i += 1; + } + html.push(`

    ${paragraphLines.join("
    ")}

    `); + } + + return html.join(""); +}; + +export const MarkdownMessage: Component<{ + content: string; + class?: string; +}> = (props) => { + const html = createMemo(() => renderMarkdown(props.content)); + + return ( +
    + ); +}; diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index e22306f..67a44ff 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -1,5 +1,5 @@ import { type Component, For, Show } from "solid-js"; -import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, Bot } from "lucide-solid"; +import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box, BarChart3, HelpCircle, MessageCircle } from "lucide-solid"; import { cn } from "@/lib/utils"; import { Button } from "./ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -42,7 +42,6 @@ const mobileNavGroups: MobileNavGroup[] = [ { icon: LayoutDashboard, label: "Matrix", view: "matrix" }, ] }, - { icon: Bot, label: "AI Help", view: "help_ai" }, { icon: Gauge, label: "Flow", items: [ { icon: Snowflake, label: "Snowball", view: "snowball" }, @@ -50,6 +49,7 @@ const mobileNavGroups: MobileNavGroup[] = [ { icon: BarChart3, label: "Progress", view: "progress" }, ] }, + { icon: MessageCircle, label: "Ask", view: "help_ai" }, { icon: Settings, label: "Settings", view: "settings" }, ]; @@ -306,6 +306,7 @@ export const Sidebar: Component<{ setView: (v: string) => void; isAIHelpOpen: boolean; onToggleAIHelp: () => void; + aiHelpTip?: string | null; isLocked: boolean; setIsLocked: (v: boolean) => void; isPeeking: boolean; @@ -356,7 +357,17 @@ export const Sidebar: Component<{
    - +
    + { + setSwitcherOpen(open); + props.onDropdownOpenChange?.(open); + }} + /> +
    - {/* Context Switcher - Only visible if there are oversight contexts */} -
    - { - setSwitcherOpen(open); - props.onDropdownOpenChange?.(open); - }} - /> -
    -