Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions src/app/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import {
Moon,
PanelLeftClose,
PanelLeftOpen,
PanelRightClose,
PanelRightOpen,
ReceiptText,
Settings,
Sun,
Expand Down Expand Up @@ -110,7 +108,6 @@ export function AppShell() {
}, [])

const sidebarToggleLabel = sidebarCollapsed ? "Expand navigation" : "Collapse navigation"
const assistantToggleLabel = assistantOpen ? "Close assistant" : "Open assistant"

return (
<AppLockGate>
Expand All @@ -124,20 +121,6 @@ export function AppShell() {
<span>BudgetLens</span>
</Link>
<div className="flex items-center gap-1">
<Button
variant={assistantOpen ? "secondary" : "ghost"}
size="icon"
aria-label={assistantToggleLabel}
title={assistantToggleLabel}
aria-expanded={assistantOpen}
onClick={() => setAssistantOpen((open) => !open)}
>
{assistantOpen ? (
<PanelRightClose className="size-5" aria-hidden="true" />
) : (
<PanelRightOpen className="size-5" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon"
Expand Down
96 changes: 64 additions & 32 deletions src/features/assistant/assistant-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useCallback, useEffect, useRef, useState } from "react"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useIsMobileLayout } from "@/components/mobile/use-media-query"
import { repositories } from "@/db/repositories"
import {
extractCitations,
Expand Down Expand Up @@ -70,9 +71,11 @@ import {
isLocalBaseURL,
listProviderModels,
needsHostedConsent,
parseTextToolCalls,
readAssistantSettings,
requestChatTurn,
sendToolResults,
stripTextToolCalls,
toPersistableSettings,
visibleAssistantPresets,
type AssistantProviderId,
Expand Down Expand Up @@ -919,6 +922,29 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
throw caught
}

if (turn.toolCalls.length === 0 && turn.content) {
// Fallback for models that emit `<function=name>` blocks as text
// instead of native tool_calls: parse and execute them through the same
// pipeline. Anything unparseable is stripped so raw syntax never
// reaches the user.
const parsed = parseTextToolCalls(
turn.content,
ASSISTANT_TOOL_SCHEMAS.map((tool) => tool.function.name),
)
if (parsed.length > 0) {
turn = {
content: stripTextToolCalls(turn.content).trim(),
toolCalls: parsed.map((call, index) => ({
id: `text-${index}`,
name: call.name,
args: call.args,
})),
}
} else {
turn = { content: stripTextToolCalls(turn.content).trim(), toolCalls: [] }
}
}

if (turn.toolCalls.length === 0) {
setStreamingContent(null)
setMessages((current) => [
Expand Down Expand Up @@ -1250,8 +1276,12 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {

const sizeWidthClass =
layout.size === "s" ? "sm:w-[22rem]" : layout.size === "l" ? "sm:w-[32rem]" : "sm:w-[26rem]"
const useCustomSize = !layout.fullscreen && layout.custom !== null
const windowClassName = layout.fullscreen
// Small viewports always use the fullscreen sheet: the collapsed card is
// unusable at phone widths, so there is no toggle there.
const isMobileLayout = useIsMobileLayout()
const fullscreenActive = layout.fullscreen || isMobileLayout
const useCustomSize = !fullscreenActive && layout.custom !== null
const windowClassName = fullscreenActive
? "assistant-window fixed inset-3 z-40 flex h-auto max-h-none min-h-0 w-auto flex-col overflow-hidden rounded-3xl border bg-card text-card-foreground shadow-2xl sm:inset-6"
: `assistant-window fixed inset-x-4 bottom-4 z-40 flex h-[min(40rem,calc(100dvh-6rem))] max-h-[calc(100dvh-3rem)] min-h-[22rem] flex-col overflow-hidden rounded-3xl border bg-card text-card-foreground shadow-2xl sm:inset-x-auto sm:right-6 sm:bottom-6 ${sizeWidthClass} sm:max-w-[calc(100vw-2rem)] sm:min-w-[20rem]`
const windowStyle =
Expand Down Expand Up @@ -1307,7 +1337,7 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
className={windowClassName}
style={windowStyle}
>
{!layout.fullscreen && (
{!fullscreenActive && (
<button
type="button"
aria-label="Resize assistant window"
Expand All @@ -1316,17 +1346,17 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
onPointerMove={moveResizeDrag}
onPointerUp={stopResizeDrag}
onPointerCancel={stopResizeDrag}
className="absolute top-0 left-0 z-20 grid size-7 cursor-nwse-resize touch-none place-items-center rounded-br-xl text-muted-foreground opacity-60 hover:bg-accent hover:opacity-100"
className="absolute top-0 left-0 z-20 hidden size-7 cursor-nwse-resize touch-none place-items-center rounded-br-xl text-muted-foreground opacity-60 hover:bg-accent hover:opacity-100 [@media(pointer:fine)]:grid"
>
<span aria-hidden="true" className="flex flex-col items-start gap-[3px] p-1.5">
<span className="block h-px w-3 rotate-[-45deg] bg-current" />
<span className="block h-px w-2 rotate-[-45deg] bg-current" />
</span>
</button>
)}
<header className="flex items-center gap-3 border-b px-4 py-3">
<span className="grid size-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Bot className="size-5" aria-hidden="true" />
<header className="flex items-center gap-1 border-b px-3 py-2.5 sm:gap-3 sm:px-4 sm:py-3">
<span className="grid size-8 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground sm:size-9">
<Bot className="size-4 sm:size-5" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm font-semibold">
Expand All @@ -1337,7 +1367,7 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
</span>
)}
</p>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<p className="flex items-center gap-1.5 truncate text-xs text-muted-foreground">
<span
aria-hidden="true"
className={`size-1.5 rounded-full ${busy ? "animate-pulse bg-amber-500" : "bg-emerald-500"}`}
Expand All @@ -1348,7 +1378,7 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
<Button
variant="ghost"
size="icon"
className="size-8"
className="size-7 sm:size-8"
aria-label="Chat history"
title="Chat history"
aria-expanded={showHistory}
Expand All @@ -1359,7 +1389,7 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
<Button
variant="ghost"
size="icon"
className="size-8"
className="size-7 sm:size-8"
aria-label="Assistant settings"
title="Assistant settings"
aria-expanded={showSettings}
Expand All @@ -1370,7 +1400,7 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
<Button
variant="ghost"
size="icon"
className="size-8"
className="size-7 sm:size-8"
aria-label="New chat"
title="New chat"
onClick={() => {
Expand All @@ -1379,27 +1409,29 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
>
<Plus className="size-4" aria-hidden="true" />
</Button>
{!isMobileLayout && (
<Button
variant="ghost"
size="icon"
className="size-7 sm:size-8"
aria-label={layout.fullscreen ? "Exit fullscreen" : "Expand to fullscreen"}
title={layout.fullscreen ? "Exit fullscreen" : "Expand to fullscreen"}
aria-pressed={layout.fullscreen}
onClick={() => {
setLayout((current) => ({ ...current, fullscreen: !current.fullscreen }))
}}
>
{layout.fullscreen ? (
<Minimize2 className="size-4" aria-hidden="true" />
) : (
<Maximize2 className="size-4" aria-hidden="true" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={layout.fullscreen ? "Exit fullscreen" : "Expand to fullscreen"}
title={layout.fullscreen ? "Exit fullscreen" : "Expand to fullscreen"}
aria-pressed={layout.fullscreen}
onClick={() => {
setLayout((current) => ({ ...current, fullscreen: !current.fullscreen }))
}}
>
{layout.fullscreen ? (
<Minimize2 className="size-4" aria-hidden="true" />
) : (
<Maximize2 className="size-4" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="size-8"
className="size-7 sm:size-8"
aria-label="Close assistant"
onClick={onClose}
>
Expand Down Expand Up @@ -1775,7 +1807,7 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
ref={logRef}
role="log"
aria-label="Assistant conversation"
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-3 py-4 sm:px-4"
>
{messages.length === 0 && !busy && (
<div className="flex h-full flex-col items-center justify-center gap-4 text-center">
Expand Down Expand Up @@ -1809,13 +1841,13 @@ export function AssistantPanel({ onClose }: { onClose: () => void }) {
{messages.map((item) =>
item.role === "user" ? (
<div key={item.id} className="flex justify-end">
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm text-primary-foreground">
<p className="max-w-[92%] rounded-2xl rounded-br-md bg-primary px-3.5 py-2 text-sm text-primary-foreground sm:max-w-[85%]">
{item.content}
</p>
</div>
) : (
<div key={item.id} className="flex justify-start">
<div className="max-w-[92%] space-y-2 rounded-2xl rounded-bl-md bg-muted px-3.5 py-2.5 text-sm">
<div className="max-w-[97%] space-y-2 rounded-2xl rounded-bl-md bg-muted px-3.5 py-2.5 text-sm sm:max-w-[92%]">
<Markdown
text={item.citedText ?? item.content}
id={item.id}
Expand Down
2 changes: 1 addition & 1 deletion src/features/assistant/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export function Composer({
</Button>
)}
</div>
<p className="px-4 pt-1 text-[11px] text-muted-foreground">
<p className="hidden px-4 pt-1 text-[11px] text-muted-foreground [@media(pointer:fine)]:block">
Enter to send · Shift+Enter newline
</p>
</div>
Expand Down
1 change: 1 addition & 0 deletions src/features/assistant/data-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@ export const ASSISTANT_SYSTEM_PROMPT = [
"Rules:",
"- Prefer spending_by_category / budget_status aggregates over raw rows.",
"- Never invent transactions, balances, or budget numbers; call a tool first.",
"- Always call tools through native function calling; never emit <function>, <tool_call>, or JSON tool-call blocks as text.",
"- Amounts are in minor units in tool I/O; show formatted currency to the user.",
"- propose_budget_change, propose_recategorize, create_transaction, delete_transaction, propose_transaction, save_chart only draft; the UI applies them after explicit approval.",
"- propose_transaction parses natural-language adds (e.g. 'spent $12 on coffee yesterday'); save_chart persists the current answer's chart spec to the dashboard.",
Expand Down
60 changes: 60 additions & 0 deletions src/features/assistant/provider-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"
import {
isLocalBaseURL,
listProviderModels,
parseTextToolCalls,
readAssistantSettings,
requestChatTurn,
stripTextToolCalls,
} from "@/features/assistant/provider"

interface StubResponse {
Expand Down Expand Up @@ -253,3 +255,61 @@ describe("requestChatTurn abort compatibility (no AbortSignal.any)", () => {
).rejects.toThrow(DOMException)
})
})

describe("parseTextToolCalls", () => {
const valid = ["spending_by_category", "budget_status"]

it("parses the reported <function=> shape, unclosed", () => {
const content = [
"Make a graph of my spending",
"<function=spending_by_category>",
"<parameter=startDate>",
"2025-08-01",
"</parameter>",
"<parameter=endDate>",
"2025-08-31",
"</parameter>",
].join("\n")
expect(parseTextToolCalls(content, valid)).toEqual([
{
name: "spending_by_category",
args: { startDate: "2025-08-01", endDate: "2025-08-31" },
},
])
})

it("handles closed blocks, quoted attrs, and multiple calls in order", () => {
const content = [
'<function="budget_status"><parameter="period">month</parameter></function>',
"between",
"<function=spending_by_category><parameter=startDate>2025-01-01</parameter></function>",
].join("\n")
expect(parseTextToolCalls(content, valid)).toEqual([
{ name: "budget_status", args: { period: "month" } },
{ name: "spending_by_category", args: { startDate: "2025-01-01" } },
])
})

it("ignores unknown tool names and prose without blocks", () => {
expect(parseTextToolCalls("Just a plain answer.", valid)).toEqual([])
expect(
parseTextToolCalls(
"<function=delete_everything><parameter=x>1</parameter></function>",
valid,
),
).toEqual([])
expect(parseTextToolCalls("", valid)).toEqual([])
})
})

describe("stripTextToolCalls", () => {
it("removes blocks but keeps surrounding prose", () => {
const content =
"Here you go:\n<function=spending_by_category>\n<parameter=startDate>2025-08-01</parameter>\nDone!"
expect(stripTextToolCalls(content)).toBe("Here you go:\n\nDone!")
})

it("leaves prose without blocks untouched", () => {
expect(stripTextToolCalls("Plain answer.")).toBe("Plain answer.")
})
})
Loading