Private
Public Access
1
0

Add floating AI chat panel with page context awareness

Replace the standalone /chat page with a bottom-right floating widget
available on every page. The panel sends page context with each message
so the AI knows what the user is viewing. On the Transactions page,
the full visible transaction list (with filters) is serialized into
the context so the AI can answer questions about on-screen entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-10 23:51:52 -05:00
parent 482df9d94d
commit c2a4272585
8 changed files with 493 additions and 31 deletions
@@ -1,7 +1,7 @@
<template>
<div>
<div class="d-flex align-center mb-4">
<h1 class="text-h4">{{ accountName || 'Transactions' }}</h1>
<h1 class="text-h4">Transactions</h1>
<v-spacer />
<v-btn
color="info"
@@ -20,16 +20,28 @@
<v-card class="mb-4">
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<v-select
v-model="accountFilter"
label="Account"
:items="accountOptions"
item-title="name"
item-value="id"
density="compact"
clearable
@update:model-value="onAccountFilterChange"
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field v-model="search" label="Search" prepend-icon="mdi-magnify" clearable density="compact" @update:model-value="doSearch" />
</v-col>
<v-col cols="12" md="3">
<v-col cols="12" md="2">
<v-text-field v-model="startDate" label="Start Date" type="date" density="compact" @update:model-value="doSearch" />
</v-col>
<v-col cols="12" md="3">
<v-col cols="12" md="2">
<v-text-field v-model="endDate" label="End Date" type="date" density="compact" @update:model-value="doSearch" />
</v-col>
<v-col cols="12" md="3">
<v-col cols="12" md="2">
<v-select v-model="statusFilter" label="Status" :items="['All','Uncleared','Cleared','Reconciled']" density="compact" @update:model-value="doSearch" />
</v-col>
</v-row>
@@ -98,7 +110,7 @@
<v-card>
<v-card-title>{{ editingTxn ? 'Edit Transaction' : 'Add Transaction' }}</v-card-title>
<v-card-text>
<v-select v-if="!props.id" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
<v-select v-if="!accountFilter" v-model="txnForm.accountId" label="Account" :items="accountsStore.accounts" item-title="name" item-value="id" />
<v-text-field v-model="txnForm.date" label="Date" type="date" />
<v-text-field v-model.number="txnForm.amount" label="Amount" type="number" prefix="$" />
<v-select v-model="txnForm.type" label="Type" :items="['Debit','Credit']" />
@@ -187,22 +199,26 @@
</v-card>
</v-dialog>
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="4000">
<v-snackbar v-model="classifySnackbar" :color="classifySnackbarColor" :timeout="5000">
{{ classifySnackbarText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { transactionsApi } from '@/services/transactions'
import { categoriesApi } from '@/services/categories'
import { aiCategorizationApi } from '@/services/aiCategorization'
import { useAccountsStore } from '@/stores/accounts'
import { useNotifications } from '@/composables/useNotifications'
import { usePageContext } from '@/composables/usePageContext'
import { formatCurrency, formatDate } from '@/utils/formatters'
import type { Transaction, Category, SplitFormItem } from '@/types'
import type { Transaction, Category, SplitFormItem, AiClassificationNotification } from '@/types'
const props = defineProps<{ id?: string }>()
const router = useRouter()
const accountsStore = useAccountsStore()
const transactions = ref<Transaction[]>([])
const totalCount = ref(0)
@@ -210,11 +226,37 @@ const page = ref(1)
const itemsPerPage = ref(50)
const categories = ref<Category[]>([])
const loading = ref(false)
const accountName = ref('')
const accountFilter = ref<string | null>(props.id || null)
const search = ref('')
const startDate = ref('')
const endDate = ref('')
const statusFilter = ref('All')
const { setPageContext, clearPageContext } = usePageContext()
watchEffect(() => {
const filters: string[] = []
if (accountFilter.value) {
const acct = accountsStore.accounts.find(a => a.id === accountFilter.value)
filters.push(`account="${acct?.name || accountFilter.value}"`)
}
if (search.value) filters.push(`search="${search.value}"`)
if (startDate.value) filters.push(`from ${startDate.value}`)
if (endDate.value) filters.push(`to ${endDate.value}`)
if (statusFilter.value !== 'All') filters.push(`status=${statusFilter.value}`)
const filterDesc = filters.length > 0 ? ` (${filters.join(', ')})` : ''
const lines = [`Transactions page${filterDesc} — showing ${transactions.value.length} of ${totalCount.value} transactions:`]
for (const txn of transactions.value) {
lines.push(`- ${txn.date.split('T')[0]} | ${txn.payeeName || '—'} | ${txn.categoryName || 'Uncategorized'} | ${txn.amount >= 0 ? '+' : ''}${txn.amount.toFixed(2)} | ${txn.status} | ID:${txn.id}`)
}
setPageContext(lines.join('\n'))
})
onUnmounted(() => clearPageContext())
const showAddDialog = ref(false)
const showTransferDialog = ref(false)
const editingTxn = ref(false)
@@ -255,17 +297,26 @@ const splitRemaining = computed(() => {
return Math.round((total - splitTotal) * 100) / 100
})
const accountOptions = computed(() => accountsStore.accounts)
function onAccountFilterChange(value: string | null) {
if (value) {
router.replace({ name: 'account-transactions', params: { id: value } })
} else {
router.replace({ name: 'transactions' })
}
}
onMounted(async () => {
await accountsStore.fetchAccounts()
loadCategories()
if (props.id) {
txnForm.value.accountId = props.id
const acct = accountsStore.accounts.find(a => a.id === props.id)
accountName.value = acct?.name || ''
}
})
watch(() => props.id, () => {
watch(() => props.id, (newId) => {
accountFilter.value = newId || null
page.value = 1
loadTransactions()
})
@@ -280,13 +331,14 @@ async function loadCategories() {
async function loadTransactions() {
loading.value = true
try {
if (props.id && !search.value && !startDate.value && !endDate.value && statusFilter.value === 'All') {
const { data } = await transactionsApi.getByAccount(props.id, page.value, itemsPerPage.value)
const acctId = accountFilter.value || undefined
if (acctId && !search.value && !startDate.value && !endDate.value && statusFilter.value === 'All') {
const { data } = await transactionsApi.getByAccount(acctId, page.value, itemsPerPage.value)
transactions.value = data.items
totalCount.value = data.totalCount
} else {
const { data } = await transactionsApi.search({
accountId: props.id || undefined,
accountId: acctId,
searchText: search.value || undefined,
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
@@ -320,7 +372,7 @@ function openAddDialog() {
editingTxn.value = false
editingTxnId.value = ''
txnForm.value = {
accountId: props.id || '',
accountId: accountFilter.value || '',
date: new Date().toISOString().split('T')[0],
amount: 0,
type: 'Debit',
@@ -388,7 +440,7 @@ async function saveTxn() {
if (editingTxn.value) {
await transactionsApi.update(editingTxnId.value, payload)
} else {
payload.accountId = txnForm.value.accountId || props.id
payload.accountId = txnForm.value.accountId || accountFilter.value
await transactionsApi.create(payload)
}
showAddDialog.value = false
@@ -407,25 +459,42 @@ async function createTransfer() {
await loadTransactions()
}
const { notifications } = useNotifications()
watch(notifications, (items) => {
const latest = items.find(n => n.type === 'AiClassificationComplete' && !n.read)
if (!latest || !classifying.value) return
const data = latest.data as AiClassificationNotification
if (data.error) {
classifySnackbarColor.value = 'error'
classifySnackbarText.value = data.error
} else {
classifySnackbarColor.value = 'success'
classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions`
}
classifySnackbar.value = true
classifying.value = false
latest.read = true
loadTransactions()
}, { deep: true })
async function classifyUncategorized() {
classifying.value = true
try {
const { data } = await aiCategorizationApi.classifyUncategorized({
accountId: props.id || undefined,
await aiCategorizationApi.classifyUncategorized({
accountId: accountFilter.value || undefined,
startDate: startDate.value || undefined,
endDate: endDate.value || undefined,
searchText: search.value || undefined,
status: statusFilter.value === 'All' ? undefined : statusFilter.value,
})
classifySnackbarColor.value = 'success'
classifySnackbarText.value = `Classified ${data.classified} of ${data.totalUncategorized} uncategorized transactions`
classifySnackbarColor.value = 'info'
classifySnackbarText.value = 'Classification started. You will be notified when it completes.'
classifySnackbar.value = true
await doSearch()
} catch (e: any) {
classifySnackbarColor.value = 'error'
classifySnackbarText.value = e?.response?.data?.message || 'Failed to classify transactions'
classifySnackbarText.value = e?.response?.data?.message || 'Failed to start classification'
classifySnackbar.value = true
} finally {
classifying.value = false
}
}