aaf2tbz 33b427ed33
feat(ui): improve mobile sidebar and dashboard layout (#67)
* feat(ui): improve mobile sidebar and dashboard layout

- Enlarge compass logo on dashboard page (size-14 idle, size-10 active)
- Reposition logo higher with -mt-16 margin
- Add 6rem spacing between logo and chat
- Remove feedback hover button from bottom right
- Add event-based feedback dialog opening for mobile sidebar
- Remove feedback buttons from site header (mobile and desktop)
- Add mobile theme toggle button to header
- Increase mobile menu hitbox to size-10
- Reduce search hitbox to separate clickable area
- Remove redundant Compass/Get Help/Assistant/Search from sidebar
- Rename "People" to "Team"
- Add mobile-only feedback button to sidebar footer
- Reduce mobile sidebar width to 10rem max-width
- Center sidebar menu icons and labels on mobile
- Clean up mobile-specific padding variants

* chore: add local development setup system

- Create .dev-setup directory with patches and scripts
- Add apply-dev.sh to easily enable local dev without WorkOS
- Add restore-dev.sh to revert to original code
- Document all changes in README.md
- Store cloudflare-context.ts in files/ as new dev-only file
- Support re-apply patches for fresh development sessions

This allows running Compass locally without WorkOS authentication
for development and testing purposes.

---------

Co-authored-by: Avery Felts <averyfelts@Averys-MacBook-Air.local>
2026-02-11 12:49:51 -07:00

902 lines
25 KiB
TypeScript
Executable File

"use client"
import { useState, useCallback, useRef, useEffect, memo } from "react"
import {
CopyIcon,
ThumbsUpIcon,
ThumbsDownIcon,
RefreshCcwIcon,
Check,
MicIcon,
XIcon,
Loader2Icon,
SquarePenIcon,
} from "lucide-react"
import {
IconBrandGithub,
IconExternalLink,
IconGitFork,
IconStar,
IconAlertCircle,
IconEye,
} from "@tabler/icons-react"
import {
isTextUIPart,
isToolUIPart,
isReasoningUIPart,
type UIMessage,
type ToolUIPart,
type DynamicToolUIPart,
} from "ai"
import { cn } from "@/lib/utils"
import {
Reasoning,
ReasoningTrigger,
ReasoningContent,
} from "@/components/ai/reasoning"
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai/conversation"
import {
Message,
MessageContent,
MessageResponse,
} from "@/components/ai/message"
import { Actions, Action } from "@/components/ai/actions"
import {
Suggestions,
Suggestion,
} from "@/components/ai/suggestion"
import {
Tool,
ToolHeader,
ToolContent,
ToolInput,
ToolOutput,
} from "@/components/ai/tool"
import { Loader } from "@/components/ai/loader"
import {
PromptInput,
PromptInputTextarea,
PromptInputFooter,
PromptInputSubmit,
PromptInputTools,
PromptInputButton,
} from "@/components/ai/prompt-input"
import { useAudioRecorder } from "@/hooks/use-audio-recorder"
import type { AudioRecorder } from "@/hooks/use-audio-recorder"
import { AudioWaveform } from "@/components/ai/audio-waveform"
import { useChatState } from "./chat-provider"
import { ModelDropdown } from "./model-dropdown"
import { getRepoStats } from "@/app/actions/github"
type RepoStats = {
readonly stargazers_count: number
readonly forks_count: number
readonly open_issues_count: number
readonly subscribers_count: number
}
interface ChatViewProps {
readonly variant: "page" | "panel"
readonly minimal?: boolean
readonly hideSuggestions?: boolean
onActivate?: () => void
readonly inputPlaceholder?: string
}
const REPO = "High-Performance-Structures/compass"
const GITHUB_URL = `https://github.com/${REPO}`
const ANIMATED_PLACEHOLDERS = [
"Show me open invoices",
"What's on the schedule for next week?",
"Which subcontractors are waiting on payment?",
"Pull up the current project timeline",
"Find outstanding invoices over 30 days",
"Who's assigned to the foundation work?",
]
const DASHBOARD_SUGGESTIONS = [
"What can you help me with?",
"Show me today's tasks",
"Navigate to customers",
]
const LOGO_MASK = {
maskImage: "url(/logo-black.png)",
maskSize: "contain",
maskRepeat: "no-repeat",
WebkitMaskImage: "url(/logo-black.png)",
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
} as React.CSSProperties
function getSuggestionsForPath(pathname: string): string[] {
if (pathname.includes("/customers")) {
return [
"Show me all customers",
"Create a new customer",
"Find customers without email",
]
}
if (pathname.includes("/vendors")) {
return [
"List all vendors",
"Add a new subcontractor",
"Show vendors by category",
]
}
if (pathname.includes("/schedule")) {
return [
"What tasks are on the critical path?",
"Show overdue tasks",
"Add a new task",
]
}
if (pathname.includes("/finances")) {
return [
"Show overdue invoices",
"What payments are pending?",
"Create a new invoice",
]
}
if (pathname.includes("/projects")) {
return [
"List all active projects",
"Create a new project",
"Which projects are behind schedule?",
]
}
if (pathname.includes("/netsuite")) {
return [
"Sync customers from NetSuite",
"Check for sync conflicts",
"When was the last sync?",
]
}
return DASHBOARD_SUGGESTIONS
}
const TOOL_DISPLAY_NAMES: Record<string, string> = {
queryData: "Looking up records",
queryGitHub: "Checking development status",
createGitHubIssue: "Creating GitHub issue",
saveInterviewFeedback: "Saving your feedback",
navigateTo: "Navigating",
showNotification: "Sending notification",
generateUI: "Building interface",
}
function friendlyToolName(raw: string): string {
return TOOL_DISPLAY_NAMES[raw] ?? raw
}
interface ChatMessageProps {
readonly msg: UIMessage
readonly copiedId: string | null
readonly onCopy: (id: string, text: string) => void
readonly onRegenerate: () => void
readonly isStreaming?: boolean
}
type AnyToolPart = ToolUIPart | DynamicToolUIPart
function extractToolName(part: AnyToolPart): string {
if (part.type === "dynamic-tool") {
return part.toolName ?? ""
}
return part.type.slice(5)
}
// renders parts in their natural order from the AI SDK
const ChatMessage = memo(
function ChatMessage({
msg,
copiedId,
onCopy,
onRegenerate,
isStreaming: msgStreaming = false,
}: ChatMessageProps) {
if (msg.role === "user") {
const text = msg.parts
.filter(isTextUIPart)
.map((p) => p.text)
.join("")
return (
<Message from="user">
<MessageContent>{text}</MessageContent>
</Message>
)
}
// walk parts sequentially, flushing text when
// hitting a tool or reasoning part to preserve
// interleaving. text flushed before the final
// segment is "thinking" (intermediate chain-of-
// thought) and rendered muted + collapsible.
const elements: React.ReactNode[] = []
let pendingText = ""
let allText = ""
let pendingReasoning = ""
let reasoningStreaming = false
let sawToolPart = false
const flushThinking = (
text: string,
idx: number,
streaming = false
) => {
if (!text) return
elements.push(
<Reasoning
key={`think-${idx}`}
isStreaming={streaming}
defaultOpen={false}
>
<ReasoningTrigger />
<ReasoningContent>{text}</ReasoningContent>
</Reasoning>
)
}
const flushText = (idx: number, isFinal: boolean) => {
if (!pendingText) return
if (!isFinal) {
// intermediate text before more tools = thinking
flushThinking(pendingText, idx)
} else {
elements.push(
<MessageContent key={`text-${idx}`}>
<MessageResponse>
{pendingText}
</MessageResponse>
</MessageContent>
)
}
pendingText = ""
}
for (let i = 0; i < msg.parts.length; i++) {
const part = msg.parts[i]
if (isReasoningUIPart(part)) {
pendingReasoning += part.text
reasoningStreaming = part.state === "streaming"
continue
}
if (isTextUIPart(part)) {
pendingText += part.text
allText += part.text
continue
}
if (isToolUIPart(part)) {
sawToolPart = true
// flush reasoning accumulated before this tool
flushThinking(pendingReasoning, i, reasoningStreaming)
pendingReasoning = ""
reasoningStreaming = false
// flush text as thinking (not final)
flushText(i, false)
const tp = part as AnyToolPart
const rawName = extractToolName(tp)
elements.push(
<Tool key={tp.toolCallId}>
<ToolHeader
title={
friendlyToolName(rawName) || "Working"
}
type={tp.type as ToolUIPart["type"]}
state={tp.state}
/>
<ToolContent>
<ToolInput input={tp.input} />
{(tp.state === "output-available" ||
tp.state === "output-error") && (
<ToolOutput
output={tp.output}
errorText={tp.errorText}
/>
)}
</ToolContent>
</Tool>
)
}
}
// flush remaining reasoning
flushThinking(
pendingReasoning,
msg.parts.length,
reasoningStreaming
)
// while streaming, if no tool calls have arrived yet
// and text is substantial, it's likely chain-of-thought
// that'll be reclassified as thinking once tools come in.
// render it collapsed so it doesn't flood the screen.
const COT_THRESHOLD = 500
if (
msgStreaming &&
!sawToolPart &&
pendingText.length > COT_THRESHOLD
) {
flushThinking(pendingText, msg.parts.length, true)
pendingText = ""
}
// flush remaining text as the final response
flushText(msg.parts.length, true)
const hasContent = elements.length > 0
return (
<Message from="assistant">
{hasContent ? elements : <Loader />}
{allText && (
<Actions>
<Action
tooltip="Copy"
onClick={() => onCopy(msg.id, allText)}
>
{copiedId === msg.id ? (
<Check className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
</Action>
<Action tooltip="Good response">
<ThumbsUpIcon className="size-4" />
</Action>
<Action tooltip="Bad response">
<ThumbsDownIcon className="size-4" />
</Action>
<Action
tooltip="Regenerate"
onClick={onRegenerate}
>
<RefreshCcwIcon className="size-4" />
</Action>
</Actions>
)}
</Message>
)
},
(prev, next) => {
if (prev.msg !== next.msg) return false
if (prev.onCopy !== next.onCopy) return false
if (prev.onRegenerate !== next.onRegenerate)
return false
if (prev.isStreaming !== next.isStreaming)
return false
const prevCopied = prev.copiedId === prev.msg.id
const nextCopied = next.copiedId === next.msg.id
if (prevCopied !== nextCopied) return false
return true
}
)
function ChatInput({
textareaRef,
placeholder,
recorder,
status,
isGenerating,
onSend,
onNewChat,
className,
onActivate,
}: {
readonly textareaRef: React.RefObject<
HTMLTextAreaElement | null
>
readonly placeholder: string
readonly recorder: AudioRecorder
readonly status: string
readonly isGenerating: boolean
readonly onSend: (text: string) => void
readonly onNewChat?: () => void
readonly className?: string
readonly onActivate?: () => void
}) {
const isRecording = recorder.state === "recording"
const isTranscribing = recorder.state === "transcribing"
const isIdle = recorder.state === "idle"
return (
<PromptInput
className={className}
onClickCapture={onActivate}
onFocusCapture={onActivate}
onSubmit={({ text }) => {
if (!text.trim() || isGenerating) return
onSend(text.trim())
}}
>
{/* textarea stays mounted (hidden) to preserve value */}
<PromptInputTextarea
ref={textareaRef}
placeholder={placeholder}
className={isIdle ? undefined : "hidden"}
/>
{/* recording: waveform + cancel/confirm on one row */}
{isRecording && recorder.stream && (
<div className="flex items-center gap-2 px-2 py-3">
<AudioWaveform
stream={recorder.stream}
className="flex-1 h-8"
/>
<PromptInputButton
onClick={recorder.cancel}
>
<XIcon className="size-4" />
</PromptInputButton>
<PromptInputButton
onClick={recorder.stop}
>
<Check className="size-4" />
</PromptInputButton>
</div>
)}
{/* transcribing */}
{isTranscribing && (
<div className="flex items-center justify-center gap-2 px-3 py-3 min-h-10 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
<span>Transcribing...</span>
</div>
)}
{/* footer: mic + submit (hidden during recording/transcribing) */}
{!isRecording && !isTranscribing && (
<PromptInputFooter>
<PromptInputTools>
{onNewChat && (
<PromptInputButton onClick={onNewChat} aria-label="New chat">
<SquarePenIcon className="size-4" />
</PromptInputButton>
)}
<ModelDropdown />
</PromptInputTools>
<div className="flex items-center gap-1">
<PromptInputButton
disabled={
!recorder.supported || !isIdle
}
onClick={() => {
recorder.start()
}}
>
<MicIcon className="size-4" />
</PromptInputButton>
<PromptInputSubmit
status={
status as
| "streaming"
| "submitted"
| "ready"
| "error"
}
/>
</div>
</PromptInputFooter>
)}
</PromptInput>
)
}
export function ChatView({
variant,
minimal = false,
hideSuggestions = false,
onActivate,
inputPlaceholder,
}: ChatViewProps) {
const chat = useChatState()
const isPage = variant === "page"
const textareaRef = useRef<HTMLTextAreaElement>(null)
// fetch repo stats client-side (page variant only)
const [stats, setStats] = useState<RepoStats | null>(null)
const statsFetched = useRef(false)
useEffect(() => {
if (!isPage || statsFetched.current) return
statsFetched.current = true
getRepoStats().then(setStats)
}, [isPage])
const handleTranscription = useCallback(
(text: string) => {
const ta = textareaRef.current
if (!ta) return
const cur = ta.value
ta.value = cur + (cur ? " " : "") + text
ta.dispatchEvent(
new Event("input", { bubbles: true })
)
},
[]
)
const recorder = useAudioRecorder(handleTranscription)
const [isActive, setIsActive] = useState(false)
const [copiedId, setCopiedId] = useState<string | null>(
null
)
// typewriter animation state (page variant only)
const [animatedPlaceholder, setAnimatedPlaceholder] =
useState("")
const animTimerRef =
useRef<ReturnType<typeof setTimeout>>(undefined)
// if returning to page variant with existing messages,
// jump straight to active
useEffect(() => {
if (isPage && chat.messages.length > 0 && !isActive) {
setIsActive(true)
}
}, [isPage, chat.messages.length, isActive])
// typewriter animation for idle input (page variant)
useEffect(() => {
if (!isPage || isActive) {
setAnimatedPlaceholder("")
return
}
let msgIdx = 0
let charIdx = 0
let phase: "typing" | "pause" | "fading" = "typing"
const tick = () => {
const msg = ANIMATED_PLACEHOLDERS[msgIdx]
if (phase === "typing") {
charIdx++
setAnimatedPlaceholder(msg.slice(0, charIdx))
if (charIdx >= msg.length) {
phase = "pause"
animTimerRef.current = setTimeout(tick, 2500)
} else {
animTimerRef.current = setTimeout(
tick,
25 + Math.random() * 20
)
}
} else if (phase === "pause") {
phase = "fading"
animTimerRef.current = setTimeout(tick, 400)
} else {
msgIdx =
(msgIdx + 1) % ANIMATED_PLACEHOLDERS.length
charIdx = 1
setAnimatedPlaceholder(
ANIMATED_PLACEHOLDERS[msgIdx].slice(0, 1)
)
phase = "typing"
animTimerRef.current = setTimeout(tick, 50)
}
}
animTimerRef.current = setTimeout(tick, 600)
return () => {
if (animTimerRef.current)
clearTimeout(animTimerRef.current)
}
}, [isPage, isActive])
// escape to return to idle when no messages (page)
useEffect(() => {
if (!isPage) return
const onKey = (e: KeyboardEvent) => {
if (
e.key === "Escape" &&
isActive &&
chat.messages.length === 0
) {
setIsActive(false)
}
}
window.addEventListener("keydown", onKey)
return () =>
window.removeEventListener("keydown", onKey)
}, [isPage, isActive, chat.messages.length])
const handleCopy = useCallback(
(id: string, content: string) => {
navigator.clipboard.writeText(content)
setCopiedId(id)
setTimeout(() => setCopiedId(null), 2000)
},
[]
)
const handleSuggestion = useCallback(
(text: string) => {
if (isPage) setIsActive(true)
chat.sendMessage({ text })
},
[isPage, chat.sendMessage]
)
const handleIdleSend = useCallback(
(text: string) => {
setIsActive(true)
chat.sendMessage({ text })
},
[chat.sendMessage]
)
const handleActiveSend = useCallback(
(text: string) => {
chat.sendMessage({ text })
},
[chat.sendMessage]
)
const suggestions = isPage
? DASHBOARD_SUGGESTIONS
: getSuggestionsForPath(chat.pathname)
// --- PAGE variant ---
if (isPage) {
return (
<div className="flex flex-1 flex-col min-h-0">
{/* Compact header - active only */}
<div
className={cn(
"shrink-0 text-center transition-all duration-500 ease-in-out overflow-hidden",
isActive
? "py-3 sm:py-4 opacity-100 max-h-40"
: "py-0 opacity-0 max-h-0"
)}
>
<span
className="mx-auto mb-2 block bg-foreground size-10"
style={LOGO_MASK}
/>
<h1 className="text-base sm:text-lg font-bold tracking-tight">
Compass
</h1>
</div>
{/* Middle content area */}
<div className="flex flex-1 flex-col min-h-0 relative">
{/* Idle hero */}
<div
className={cn(
"absolute inset-0 flex flex-col items-center justify-center",
"transition-all duration-500 ease-in-out",
isActive
? "opacity-0 translate-y-4 pointer-events-none"
: "opacity-100 translate-y-0"
)}
>
<div className="w-full max-w-2xl px-5 space-y-4 text-center">
<div className="-mt-16">
<span
className="mx-auto mb-4 block bg-foreground size-20"
style={LOGO_MASK}
/>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">
Compass
</h1>
<p className="text-muted-foreground/60 mt-1.5 text-xs px-2">
Development preview features may be
incomplete or change without notice.
</p>
</div>
<ChatInput
textareaRef={textareaRef}
placeholder={
animatedPlaceholder || "Ask anything..."
}
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={handleIdleSend}
className="rounded-2xl mt-[6rem]"
/>
{stats && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs text-muted-foreground/70">
<a
href={GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 transition-colors hover:text-foreground"
>
<IconBrandGithub className="size-4" />
<span>View on GitHub</span>
<IconExternalLink className="size-3" />
</a>
<span className="hidden sm:inline text-border">
|
</span>
<span className="text-xs">{REPO}</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<IconStar className="size-3.5" />
{stats.stargazers_count}
</span>
<span className="flex items-center gap-1">
<IconGitFork className="size-3.5" />
{stats.forks_count}
</span>
<span className="flex items-center gap-1">
<IconAlertCircle className="size-3.5" />
{stats.open_issues_count}
</span>
<span className="flex items-center gap-1">
<IconEye className="size-3.5" />
{stats.subscribers_count}
</span>
</div>
</div>
)}
</div>
</div>
{/* Active conversation */}
<div
className={cn(
"absolute inset-0 flex flex-col",
"transition-all duration-500 ease-in-out delay-100",
isActive
? "opacity-100 translate-y-0"
: "opacity-0 -translate-y-4 pointer-events-none"
)}
>
{chat.messages.length > 0 ? (
<Conversation className="flex-1">
<ConversationContent className="mx-auto w-full max-w-3xl">
{chat.messages.map((msg, idx) => (
<ChatMessage
key={msg.id}
msg={msg}
copiedId={copiedId}
onCopy={handleCopy}
onRegenerate={chat.regenerate}
isStreaming={
(chat.status === "streaming" ||
chat.status === "submitted") &&
idx === chat.messages.length - 1 &&
msg.role === "assistant"
}
/>
))}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
) : (
<div className="flex-1 flex items-end">
<div className="mx-auto w-full max-w-2xl pb-4">
<Suggestions className="justify-center px-4">
{suggestions.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={handleSuggestion}
/>
))}
</Suggestions>
</div>
</div>
)}
</div>
</div>
{/* Bottom input - active only */}
<div
className={cn(
"shrink-0 px-4 transition-all duration-500 ease-in-out",
isActive
? "opacity-100 translate-y-0 pt-2 pb-6"
: "opacity-0 translate-y-4 max-h-0 overflow-hidden pointer-events-none py-0"
)}
>
<div className="mx-auto max-w-3xl">
<ChatInput
textareaRef={textareaRef}
placeholder="Ask follow-up..."
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={handleActiveSend}
onNewChat={chat.messages.length > 0 ? chat.newChat : undefined}
className="rounded-2xl"
/>
</div>
</div>
</div>
)
}
// --- PANEL variant ---
if (minimal) {
return (
<div className="w-full p-2">
<ChatInput
textareaRef={textareaRef}
placeholder={inputPlaceholder ?? "Create a new setting"}
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={handleActiveSend}
onActivate={onActivate}
/>
</div>
)
}
return (
<div className="flex h-full w-full flex-col">
{/* Conversation */}
<Conversation className="flex-1">
<ConversationContent>
{chat.messages.length === 0 ? (
<div
className={cn(
"flex flex-col items-center gap-4",
hideSuggestions ? "h-full" : "pt-8"
)}
>
{!hideSuggestions && (
<Suggestions>
{suggestions.map((s) => (
<Suggestion
key={s}
suggestion={s}
onClick={handleSuggestion}
/>
))}
</Suggestions>
)}
</div>
) : (
chat.messages.map((msg, idx) => (
<ChatMessage
key={msg.id}
msg={msg}
copiedId={copiedId}
onCopy={handleCopy}
onRegenerate={chat.regenerate}
isStreaming={
(chat.status === "streaming" ||
chat.status === "submitted") &&
idx === chat.messages.length - 1 &&
msg.role === "assistant"
}
/>
))
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Input */}
<div className="p-3">
<ChatInput
textareaRef={textareaRef}
placeholder={inputPlaceholder ?? "Ask anything..."}
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={handleActiveSend}
onNewChat={chat.messages.length > 0 ? chat.newChat : undefined}
/>
</div>
</div>
)
}