improved chat ui
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m22s

This commit is contained in:
2026-03-25 18:38:04 -05:00
parent 47257aa007
commit 82c5f07f7a
8 changed files with 363 additions and 127 deletions
+26 -1
View File
@@ -5,7 +5,8 @@ import { EmbedAuthWrapper } from './components/EmbedAuthWrapper';
import { CriticalView } from './views/CriticalView'; import { CriticalView } from './views/CriticalView';
import { AuthCallback } from './components/Auth'; import { AuthCallback } from './components/Auth';
import { pb } from './lib/pocketbase'; 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 CriticalView = lazy(() => import('./views/CriticalView').then(m => ({ default: m.CriticalView })));
const UrgencyView = lazy(() => import('./views/UrgencyView').then(m => ({ default: m.UrgencyView }))); 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 [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
const [location, setLocation] = createSignal(window.location.href); const [location, setLocation] = createSignal(window.location.href);
const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches); const [isDesktop, setIsDesktop] = createSignal(window.matchMedia("(min-width: 768px)").matches);
const [aiHelpTip, setAiHelpTip] = createSignal<string | null>(null);
const [hasRequestedSessionTip, setHasRequestedSessionTip] = createSignal(false);
createEffect(() => { createEffect(() => {
const view = currentView(); const view = currentView();
@@ -39,6 +42,25 @@ const App: Component = () => {
currentView() === "help_ai" && isDesktop() ? lastNonAIView() : currentView() 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(() => { onMount(() => {
const mediaQuery = window.matchMedia("(min-width: 768px)"); const mediaQuery = window.matchMedia("(min-width: 768px)");
const openHelpGuide = (sectionId?: string) => { const openHelpGuide = (sectionId?: string) => {
@@ -81,6 +103,8 @@ const App: Component = () => {
const removeListener = pb.authStore.onChange((token) => { const removeListener = pb.authStore.onChange((token) => {
const wasAuthenticated = isAuthenticated(); const wasAuthenticated = isAuthenticated();
setIsAuthenticated(!!token); setIsAuthenticated(!!token);
setHasRequestedSessionTip(false);
setAiHelpTip(null);
if (token && (isFirstRun || !wasAuthenticated)) { if (token && (isFirstRun || !wasAuthenticated)) {
initStore(); initStore();
@@ -155,6 +179,7 @@ const App: Component = () => {
setView={setCurrentView} setView={setCurrentView}
isAIHelpOpen={currentView() === "help_ai" && isDesktop()} isAIHelpOpen={currentView() === "help_ai" && isDesktop()}
onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")} onToggleAIHelp={() => setCurrentView(currentView() === "help_ai" ? lastNonAIView() : "help_ai")}
aiHelpTip={aiHelpTip()}
hideQuickEntry={currentView() === "help_ai" && !isDesktop()} hideQuickEntry={currentView() === "help_ai" && !isDesktop()}
> >
<Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}> <Suspense fallback={<div class="flex items-center justify-center h-full"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div></div>}>
+68 -104
View File
@@ -1,30 +1,16 @@
import { type Component, For, Show, createEffect, createMemo, createSignal } from "solid-js"; 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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config"; import { FIREWORKS_HELP_MODEL } from "@/lib/app-config";
import helpGuide from "../../docs/HELP_GUIDE.md?raw"; import { askAIHelp, hasFireworksApiKey } from "@/lib/ai-help";
import { MarkdownMessage } from "./MarkdownMessage";
type ChatMessage = { type ChatMessage = {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; 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 = [ const SUGGESTED_PROMPTS = [
"How does sharing work in TasGrid?", "How does sharing work in TasGrid?",
"What is the difference between Handoff and Collaborative?", "What is the difference between Handoff and Collaborative?",
@@ -33,7 +19,7 @@ const SUGGESTED_PROMPTS = [
"How do recurrence and templates behave?" "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<{ export const AIHelpPanel: Component<{
variant?: "page" | "sidebar"; variant?: "page" | "sidebar";
@@ -48,7 +34,10 @@ export const AIHelpPanel: Component<{
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
let messagesViewport: HTMLDivElement | undefined; 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(() => { createEffect(() => {
messages(); messages();
@@ -79,7 +68,7 @@ export const AIHelpPanel: Component<{
...nextMessages, ...nextMessages,
{ {
role: "assistant", 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); setIsLoading(false);
@@ -87,39 +76,7 @@ export const AIHelpPanel: Component<{
} }
try { try {
const response = await fetch(FIREWORKS_API_URL, { const assistantText = await askAIHelp(nextMessages);
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.");
}
setMessages(prev => [...prev, { role: "assistant", content: assistantText }]); setMessages(prev => [...prev, { role: "assistant", content: assistantText }]);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"; 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" isSidebar() ? "px-3 py-3" : "px-4 py-4 sm:px-5"
)}> )}>
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<Bot size={16} class="text-primary shrink-0" /> <MessageCircle size={16} class="text-primary shrink-0" />
<h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>AI Help</h1> <h1 class={cn("font-semibold tracking-tight", isSidebar() ? "text-sm" : "text-lg")}>Ask Questions</h1>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -177,32 +134,12 @@ export const AIHelpPanel: Component<{
"flex min-h-0 flex-1 flex-col", "flex min-h-0 flex-1 flex-col",
isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5" isSidebar() ? "p-3 gap-3" : "p-4 gap-3 sm:p-5"
)}> )}>
<Show <Show when={!hasApiKey()}>
when={hasApiKey()} <div class={cn(
fallback={ "rounded-2xl border border-amber-500/30 bg-amber-500/10",
<div class={cn( isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm"
"rounded-2xl border border-amber-500/30 bg-amber-500/10", )}>
isSidebar() ? "p-3 text-[0.75rem]" : "p-4 text-sm" Add `VITE_FIREWORKS_API_KEY` to enable Ask Questions. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
)}>
Add `VITE_FIREWORKS_API_KEY` to enable AI Help. The view is wired to Fireworks model `{FIREWORKS_HELP_MODEL}`.
</div>
}
>
<div class="flex flex-wrap gap-2">
<For each={SUGGESTED_PROMPTS}>
{(prompt) => (
<button
class={cn(
"rounded-full border border-border bg-background text-left font-medium text-foreground transition-colors hover:bg-muted",
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
)}
onClick={() => void submitQuestion(prompt)}
disabled={isLoading()}
>
{prompt}
</button>
)}
</For>
</div> </div>
</Show> </Show>
@@ -215,24 +152,51 @@ export const AIHelpPanel: Component<{
> >
<For each={messages()}> <For each={messages()}>
{(message) => ( {(message) => (
<div class={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}> <div class="space-y-2">
<div <div class={cn("flex", message.role === "user" ? "justify-end" : "justify-start")}>
class={cn( <div
"rounded-2xl px-4 py-3 leading-relaxed whitespace-pre-wrap shadow-sm", class={cn(
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[90%] sm:max-w-[80%] text-sm", "rounded-2xl px-4 py-3 leading-relaxed whitespace-pre-wrap shadow-sm",
message.role === "user" isSidebar() ? "max-w-[92%]" : "max-w-[90%] sm:max-w-[80%]",
? "bg-primary text-primary-foreground" message.role === "user"
: "bg-card border border-border text-foreground" ? "bg-primary text-primary-foreground"
)} : "bg-card border border-border text-foreground"
> )}
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70"> >
<Show when={message.role === "assistant"} fallback={<span>You</span>}> <div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
<Bot size={12} /> <Show when={message.role === "assistant"} fallback={<span>You</span>}>
<span>Guide Assistant</span> <MessageCircle size={12} />
</Show> <span>TasGrid Help</span>
</Show>
</div>
<MarkdownMessage
content={message.content}
class={cn(
message.role === "user"
? "prose-invert prose-headings:text-primary-foreground prose-p:text-primary-foreground prose-strong:text-primary-foreground prose-code:bg-primary-foreground/15 prose-code:text-primary-foreground prose-a:text-primary-foreground prose-blockquote:text-primary-foreground"
: ""
)}
/>
</div> </div>
<div>{message.content}</div>
</div> </div>
<Show when={showSuggestions() && message.role === "assistant"}>
<div class={cn("flex flex-wrap gap-2", isSidebar() ? "pl-1" : "pl-1")}>
<For each={SUGGESTED_PROMPTS}>
{(prompt) => (
<button
class={cn(
"rounded-full border border-border bg-card text-left font-medium text-foreground transition-colors hover:bg-muted",
isSidebar() ? "px-2.5 py-1.5 text-[0.6875rem]" : "px-3 py-2 text-xs sm:text-sm"
)}
onClick={() => void submitQuestion(prompt)}
disabled={isLoading()}
>
{prompt}
</button>
)}
</For>
</div>
</Show>
</div> </div>
)} )}
</For> </For>
@@ -241,15 +205,15 @@ export const AIHelpPanel: Component<{
<div class="flex justify-start"> <div class="flex justify-start">
<div class={cn( <div class={cn(
"rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground", "rounded-2xl px-4 py-3 leading-relaxed shadow-sm bg-card border border-border text-foreground",
isSidebar() ? "max-w-[92%] text-[0.75rem]" : "max-w-[80%] text-sm" isSidebar() ? "max-w-[92%] text-sm" : "max-w-[80%] text-sm"
)}> )}>
<div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70"> <div class="mb-1 flex items-center gap-2 text-[0.625rem] font-black uppercase tracking-[0.18em] opacity-70">
<Bot size={12} /> <MessageCircle size={12} />
<span>Guide Assistant</span> <span>TasGrid Help</span>
</div> </div>
<div class="flex items-center gap-2 text-muted-foreground"> <div class="flex items-center gap-2 text-muted-foreground">
<LoaderCircle size={14} class="animate-spin" /> <LoaderCircle size={14} class="animate-spin" />
Looking through the guide... Finding an answer...
</div> </div>
</div> </div>
</div> </div>
@@ -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..." placeholder="Ask how a TasGrid feature works, what a setting does, or why something may behave a certain way..."
class={cn( 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", "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"
)} )}
/> />
<div class={cn( <div class={cn(
@@ -295,7 +259,7 @@ export const AIHelpPanel: Component<{
disabled={isLoading() || !input().trim()} disabled={isLoading() || !input().trim()}
> >
<Send size={14} /> <Send size={14} />
Ask AI Help Ask Question
</Button> </Button>
</div> </div>
</form> </form>
+2
View File
@@ -17,6 +17,7 @@ interface LayoutProps {
setView: (v: string) => void; setView: (v: string) => void;
isAIHelpOpen?: boolean; isAIHelpOpen?: boolean;
onToggleAIHelp?: () => void; onToggleAIHelp?: () => void;
aiHelpTip?: string | null;
hideQuickEntry?: boolean; hideQuickEntry?: boolean;
} }
@@ -116,6 +117,7 @@ export const Layout: Component<LayoutProps> = (props) => {
setView={props.setView} setView={props.setView}
isAIHelpOpen={!!props.isAIHelpOpen} isAIHelpOpen={!!props.isAIHelpOpen}
onToggleAIHelp={() => props.onToggleAIHelp?.()} onToggleAIHelp={() => props.onToggleAIHelp?.()}
aiHelpTip={props.aiHelpTip}
isLocked={isSidebarLocked()} isLocked={isSidebarLocked()}
setIsLocked={setIsSidebarLocked} setIsLocked={setIsSidebarLocked}
isPeeking={isSidebarPeeking()} isPeeking={isSidebarPeeking()}
+111
View File
@@ -0,0 +1,111 @@
import { type Component, createMemo } from "solid-js";
import { cn } from "@/lib/utils";
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const applyInlineMarkdown = (value: string) => {
let text = escapeHtml(value);
text = text.replace(/`([^`]+)`/g, "<code>$1</code>");
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
text = text.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?:;]|$)/g, '$1<em>$2</em>');
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(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
continue;
}
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^[-*]\s+/, ""))}</li>`);
i += 1;
}
html.push(`<ul>${items.join("")}</ul>`);
continue;
}
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
items.push(`<li>${applyInlineMarkdown(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
i += 1;
}
html.push(`<ol>${items.join("")}</ol>`);
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(`<blockquote><p>${quoteLines.join("<br />")}</p></blockquote>`);
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(`<p>${paragraphLines.join("<br />")}</p>`);
}
return html.join("");
};
export const MarkdownMessage: Component<{
content: string;
class?: string;
}> = (props) => {
const html = createMemo(() => renderMarkdown(props.content));
return (
<div
class={cn(
"prose prose-sm dark:prose-invert max-w-none",
"prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-0",
"prose-pre:my-3 prose-pre:rounded-lg prose-pre:bg-muted prose-pre:p-3",
"prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-[0.9em]",
"prose-code:before:content-none prose-code:after:content-none",
"prose-a:text-primary prose-a:underline prose-a:underline-offset-4",
"prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:pl-4 prose-blockquote:italic",
props.class
)}
innerHTML={html()}
/>
);
};
+27 -22
View File
@@ -1,5 +1,5 @@
import { type Component, For, Show } from "solid-js"; 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 { cn } from "@/lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -42,7 +42,6 @@ const mobileNavGroups: MobileNavGroup[] = [
{ icon: LayoutDashboard, label: "Matrix", view: "matrix" }, { icon: LayoutDashboard, label: "Matrix", view: "matrix" },
] ]
}, },
{ icon: Bot, label: "AI Help", view: "help_ai" },
{ {
icon: Gauge, label: "Flow", items: [ icon: Gauge, label: "Flow", items: [
{ icon: Snowflake, label: "Snowball", view: "snowball" }, { icon: Snowflake, label: "Snowball", view: "snowball" },
@@ -50,6 +49,7 @@ const mobileNavGroups: MobileNavGroup[] = [
{ icon: BarChart3, label: "Progress", view: "progress" }, { icon: BarChart3, label: "Progress", view: "progress" },
] ]
}, },
{ icon: MessageCircle, label: "Ask", view: "help_ai" },
{ icon: Settings, label: "Settings", view: "settings" }, { icon: Settings, label: "Settings", view: "settings" },
]; ];
@@ -306,6 +306,7 @@ export const Sidebar: Component<{
setView: (v: string) => void; setView: (v: string) => void;
isAIHelpOpen: boolean; isAIHelpOpen: boolean;
onToggleAIHelp: () => void; onToggleAIHelp: () => void;
aiHelpTip?: string | null;
isLocked: boolean; isLocked: boolean;
setIsLocked: (v: boolean) => void; setIsLocked: (v: boolean) => void;
isPeeking: boolean; isPeeking: boolean;
@@ -356,7 +357,17 @@ export const Sidebar: Component<{
</Show> </Show>
</div> </div>
<div class="px-3 pb-2">
<ContextSwitcher
isLocked={props.isLocked}
isPeeking={props.isPeeking}
setIsPeeking={props.setIsPeeking}
onOpenChange={(open) => {
setSwitcherOpen(open);
props.onDropdownOpenChange?.(open);
}}
/>
</div>
<nav class="flex-1 px-3 space-y-1 mt-2 min-h-0"> <nav class="flex-1 px-3 space-y-1 mt-2 min-h-0">
<For each={desktopNavItems}> <For each={desktopNavItems}>
@@ -387,34 +398,28 @@ export const Sidebar: Component<{
<AIHelpPanel variant="sidebar" class="h-[32rem]" /> <AIHelpPanel variant="sidebar" class="h-[32rem]" />
</div> </div>
{/* Context Switcher - Only visible if there are oversight contexts */}
<div class={cn(
"transition-all duration-200",
(!props.isLocked && !props.isPeeking) ? "opacity-0 pointer-events-none" : "opacity-100 delay-100"
)}>
<ContextSwitcher
isLocked={props.isLocked}
isPeeking={props.isPeeking}
setIsPeeking={props.setIsPeeking}
onOpenChange={(open) => {
setSwitcherOpen(open);
props.onDropdownOpenChange?.(open);
}}
/>
</div>
<div class="border-t border-border pt-3 space-y-1"> <div class="border-t border-border pt-3 space-y-1">
<button <button
onClick={props.onToggleAIHelp} onClick={props.onToggleAIHelp}
class={cn( class={cn(
"flex items-center space-x-3 w-full px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm", "flex w-full items-start gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 group text-sm text-left",
props.isAIHelpOpen props.isAIHelpOpen
? "bg-primary text-primary-foreground shadow-sm" ? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
)} )}
> >
<Bot size={16} class={cn("transition-transform group-hover:scale-110")} /> <MessageCircle size={16} class={cn("transition-transform group-hover:scale-110 mt-0.5 shrink-0")} />
<span class="font-medium">AI Help</span> <div class="min-w-0 flex-1">
<div class="font-medium">Ask Questions</div>
<div
class={cn(
"text-[0.625rem] leading-snug transition-opacity duration-700",
props.aiHelpTip ? "opacity-50" : "opacity-0"
)}
>
{props.aiHelpTip || " "}
</div>
</div>
</button> </button>
<button <button
onClick={() => props.setView("help")} onClick={() => props.setView("help")}
+112
View File
@@ -0,0 +1,112 @@
import { FIREWORKS_API_KEY, FIREWORKS_API_URL, FIREWORKS_HELP_MODEL } from "@/lib/app-config";
import helpGuide from "../../docs/HELP_GUIDE.md?raw";
type ChatRequestMessage = {
role: "system" | "user" | "assistant";
content: string;
};
const HELP_SYSTEM_PROMPT = `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 normalizeLine = (value: string) =>
value
.trim()
.replace(/^["'\s]+|["'\s]+$/g, "")
.replace(/\s+/g, " ")
.toLowerCase();
export const hasFireworksApiKey = () => FIREWORKS_API_KEY.trim().length > 0;
const requestFireworks = async (messages: ChatRequestMessage[], options?: { maxTokens?: number; temperature?: number }) => {
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: options?.temperature ?? 0.2,
max_tokens: options?.maxTokens ?? 700,
messages
})
});
if (!response.ok) {
const bodyText = await response.text();
throw new Error(bodyText || `Request failed with status ${response.status}`);
}
const data = await response.json();
const content = data?.choices?.[0]?.message?.content?.trim();
if (!content) {
throw new Error("The help assistant did not return any text.");
}
return content;
};
export const askAIHelp = async (messages: Array<{ role: "user" | "assistant"; content: string }>) =>
requestFireworks(
[
{ role: "system", content: HELP_SYSTEM_PROMPT },
...messages
],
{ maxTokens: 700, temperature: 0.2 }
);
export const generateAIHelpTip = async (history: string[]) => {
const recentHistory = history.slice(-10);
const historyBlock = recentHistory.length > 0
? recentHistory.map((tip, index) => `${index + 1}. ${tip}`).join("\n")
: "No previous tips.";
const tipPrompt = `Create one short TasGrid desktop tip based only on the guide.
Rules:
- Maximum one sentence.
- Return plain text only.
- No title, no bullet, no quotes.
- Keep it subtle and practical.
- Do not repeat or closely paraphrase any previous tip.
- Use only this guide for all information and never assume you know something that isn't mentioned in the guide.
Previous tips to avoid:
${historyBlock}`;
const seen = new Set(recentHistory.map(normalizeLine));
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await requestFireworks(
[
{ role: "system", content: HELP_SYSTEM_PROMPT },
{ role: "user", content: tipPrompt }
],
{ maxTokens: 80, temperature: 0.5 }
);
const firstLine = response.split("\n")[0]?.trim() || "";
const cleaned = firstLine.replace(/^[-*•\d.\s]+/, "").trim();
const normalized = normalizeLine(cleaned);
if (!cleaned || !normalized) continue;
if (seen.has(normalized)) continue;
return cleaned;
}
return null;
};
+13
View File
@@ -28,6 +28,7 @@ export const syncPreferences = async () => {
collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks, collapsedUntilUpdatedTasks: store.collapsedUntilUpdatedTasks,
subscribedBuckets: store.subscribedBuckets, subscribedBuckets: store.subscribedBuckets,
supervisorUserIds: store.personalContextSupervisorIds, supervisorUserIds: store.personalContextSupervisorIds,
aiHelpTipHistory: store.aiHelpTipHistory,
lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate lastCompletedTaskCleanupDate: store.lastCompletedTaskCleanupDate
}; };
@@ -174,3 +175,15 @@ export const removeNoteFilterTemplate = async (id: string) => {
export const clearNoteFilter = () => { export const clearNoteFilter = () => {
setStore("noteFilter", createDefaultFilter()); setStore("noteFilter", createDefaultFilter());
}; };
export const appendAIHelpTipHistory = async (tip: string) => {
const normalizedTip = tip.trim();
if (!normalizedTip) return;
setStore("aiHelpTipHistory", prev => {
const next = [...prev.filter(existing => existing !== normalizedTip), normalizedTip];
return next.slice(-10);
});
await syncPreferences();
};
+4
View File
@@ -183,6 +183,7 @@ export interface TaskStore {
buckets: Bucket[]; buckets: Bucket[];
subscribedBuckets: string[]; subscribedBuckets: string[];
personalContextSupervisorIds: string[]; personalContextSupervisorIds: string[];
aiHelpTipHistory: string[];
notes: Note[]; notes: Note[];
isNotepadMode: boolean; isNotepadMode: boolean;
quickloadTasks: string[]; quickloadTasks: string[];
@@ -202,6 +203,7 @@ export interface ScopedTaskgridPrefs {
noteFilter?: Filter; noteFilter?: Filter;
subscribedBuckets?: string[]; subscribedBuckets?: string[];
supervisorUserIds?: string[]; supervisorUserIds?: string[];
aiHelpTipHistory?: string[];
dismissedTaskUpdateIndicators?: Record<string, string>; dismissedTaskUpdateIndicators?: Record<string, string>;
collapsedUntilUpdatedTasks?: Record<string, string>; collapsedUntilUpdatedTasks?: Record<string, string>;
migratedStructuredTagsV2?: boolean; migratedStructuredTagsV2?: boolean;
@@ -246,6 +248,7 @@ export const [store, setStore] = createStore<TaskStore>({
buckets: [], buckets: [],
subscribedBuckets: [], subscribedBuckets: [],
personalContextSupervisorIds: [], personalContextSupervisorIds: [],
aiHelpTipHistory: [],
notes: [], notes: [],
isNotepadMode: false, isNotepadMode: false,
quickloadTasks: [], quickloadTasks: [],
@@ -295,6 +298,7 @@ export const applyScopedPrefsToStore = (prefs: ScopedTaskgridPrefs) => {
prefId: currentUserId, prefId: currentUserId,
subscribedBuckets: prefs.subscribedBuckets || [], subscribedBuckets: prefs.subscribedBuckets || [],
personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [], personalContextSupervisorIds: personalContext?.supervisorUserIds || prefs.supervisorUserIds || [],
aiHelpTipHistory: prefs.aiHelpTipHistory || [],
tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")), tagDefinitions: (prefs.tagDefinitions || []).filter((def: TagDefinition) => !def.name.startsWith("@")),
filterTemplates: prefs.filterTemplates || [], filterTemplates: prefs.filterTemplates || [],
quickloadTasks: prefs.quickloadTasks || [], quickloadTasks: prefs.quickloadTasks || [],