trigger & grouping

This commit is contained in:
Thomas G. Lopes 2025-06-17 11:49:33 +01:00
parent c622ff25a7
commit 3abd8b5502
10 changed files with 135 additions and 40 deletions

View file

@ -15,6 +15,7 @@
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.3257 0.1161 325.0372); --popover-foreground: oklch(0.3257 0.1161 325.0372);
--primary: oklch(0.5316 0.1409 355.1999); --primary: oklch(0.5316 0.1409 355.1999);
--heading: oklch(0.5797 0.1194 237.7893);
--primary-foreground: oklch(1 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.8696 0.0675 334.8991); --secondary: oklch(0.8696 0.0675 334.8991);
--secondary-foreground: oklch(0.4448 0.1341 324.7991); --secondary-foreground: oklch(0.4448 0.1341 324.7991);
@ -62,6 +63,7 @@
--popover: oklch(0.1548 0.0132 338.9015); --popover: oklch(0.1548 0.0132 338.9015);
--popover-foreground: oklch(0.9647 0.0091 341.8035); --popover-foreground: oklch(0.9647 0.0091 341.8035);
--primary: oklch(0.5797 0.1194 237.7893); --primary: oklch(0.5797 0.1194 237.7893);
--heading: oklch(0.85 0.1194 237.7893);
--primary-foreground: oklch(1 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.3137 0.0306 310.061); --secondary: oklch(0.3137 0.0306 310.061);
--secondary-foreground: oklch(0.8483 0.0382 307.9613); --secondary-foreground: oklch(0.8483 0.0382 307.9613);
@ -108,6 +110,7 @@
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-heading: var(--heading);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);

View file

@ -1,8 +1,9 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { Provider } from '../../types'; import { Provider } from '../../types';
import { internal } from './_generated/api'; import { internal } from './_generated/api';
import { mutation, query } from './_generated/server'; import { query } from './_generated/server';
import { providerValidator } from './schema'; import { providerValidator } from './schema';
import { mutation } from './functions';
export const all = query({ export const all = query({
args: { args: {

View file

@ -1,9 +1,10 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { api } from './_generated/api'; import { api } from './_generated/api';
import { mutation, query } from './_generated/server'; import { query } from './_generated/server';
import { type Id } from './_generated/dataModel'; import { type Id } from './_generated/dataModel';
import { type SessionObj } from './betterAuth'; import { type SessionObj } from './betterAuth';
import { messageRoleValidator } from './schema'; import { messageRoleValidator } from './schema';
import { mutation } from './functions';
export const get = query({ export const get = query({
args: { args: {
@ -24,7 +25,12 @@ export const get = query({
.withIndex('by_user', (q) => q.eq('user_id', s.userId)) .withIndex('by_user', (q) => q.eq('user_id', s.userId))
.collect(); .collect();
return conversations; return conversations.sort((a, b) => {
const aTime = a.updated_at ?? 0;
const bTime = b.updated_at ?? 0;
return bTime - aTime;
});
}, },
}); });
@ -45,6 +51,7 @@ export const create = mutation({
title: 'Untitled (for now)', title: 'Untitled (for now)',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out
user_id: session.userId as any, user_id: session.userId as any,
updated_at: Date.now(),
}); });
return res; return res;
@ -73,6 +80,7 @@ export const createAndAddMessage = mutation({
title: 'Untitled (for now)', title: 'Untitled (for now)',
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Id type is janking out
user_id: session.userId as any, user_id: session.userId as any,
updated_at: Date.now(),
}); });
const messageId = await ctx.runMutation(api.messages.create, { const messageId = await ctx.runMutation(api.messages.create, {

View file

@ -7,7 +7,7 @@ import {
import { customCtx, customMutation } from 'convex-helpers/server/customFunctions'; import { customCtx, customMutation } from 'convex-helpers/server/customFunctions';
import { Triggers } from 'convex-helpers/server/triggers'; import { Triggers } from 'convex-helpers/server/triggers';
import { DataModel } from './_generated/dataModel'; import { type Id, type DataModel } from './_generated/dataModel';
const triggers = new Triggers<DataModel>(); const triggers = new Triggers<DataModel>();
@ -24,6 +24,20 @@ triggers.register('conversations', async (ctx, change) => {
} }
}); });
// Update conversation updated_at when a message is created/updated
triggers.register('messages', async (ctx, change) => {
if (change.operation === 'insert' || change.operation === 'update') {
const conversationId = change.newDoc.conversation_id;
const conversation = await ctx.db.get(conversationId as Id<'conversations'>);
if (!conversation) return;
await ctx.db.patch(conversationId as Id<'conversations'>, {
updated_at: Date.now(),
});
}
});
// TODO: Cascade delete rules when a user is deleted // TODO: Cascade delete rules when a user is deleted
// create wrappers that replace the built-in `mutation` and `internalMutation` // create wrappers that replace the built-in `mutation` and `internalMutation`

View file

@ -1,8 +1,9 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { api } from './_generated/api'; import { api } from './_generated/api';
import { messageRoleValidator, providerValidator } from './schema';
import { type Id } from './_generated/dataModel'; import { type Id } from './_generated/dataModel';
import { query } from './_generated/server';
import { messageRoleValidator, providerValidator } from './schema';
import { mutation } from './functions';
export const getAllFromConversation = query({ export const getAllFromConversation = query({
args: { args: {

View file

@ -44,6 +44,7 @@ export default defineSchema({
conversations: defineTable({ conversations: defineTable({
user_id: v.string(), user_id: v.string(),
title: v.string(), title: v.string(),
updated_at: v.optional(v.number()),
}).index('by_user', ['user_id']), }).index('by_user', ['user_id']),
messages: defineTable({ messages: defineTable({
conversation_id: v.string(), conversation_id: v.string(),

View file

@ -1,4 +1,5 @@
import { query, mutation } from './_generated/server'; import { query } from './_generated/server';
import { mutation } from './functions';
import { v } from 'convex/values'; import { v } from 'convex/values';
import { providerValidator } from './schema'; import { providerValidator } from './schema';
import * as array from '../../utils/array'; import * as array from '../../utils/array';

View file

@ -1,7 +1,8 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { Provider } from '../../types'; import { Provider } from '../../types';
import { api, internal } from './_generated/api'; import { api, internal } from './_generated/api';
import { mutation, query } from './_generated/server'; import { query } from './_generated/server';
import { mutation } from './functions';
import { providerValidator } from './schema'; import { providerValidator } from './schema';
import { type SessionObj } from './betterAuth'; import { type SessionObj } from './betterAuth';

View file

@ -1,5 +1,6 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { mutation, query } from './_generated/server'; import { query } from './_generated/server';
import { mutation } from './functions';
import { internal } from './_generated/api'; import { internal } from './_generated/api';
import { ruleAttachValidator } from './schema'; import { ruleAttachValidator } from './schema';
import { type Doc } from './_generated/dataModel'; import { type Doc } from './_generated/dataModel';

View file

@ -16,6 +16,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { useCachedQuery } from '$lib/cache/cached-query.svelte.js'; import { useCachedQuery } from '$lib/cache/cached-query.svelte.js';
import { api } from '$lib/backend/convex/_generated/api.js'; import { api } from '$lib/backend/convex/_generated/api.js';
import { type Doc } from '$lib/backend/convex/_generated/dataModel.js';
import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js'; import { TextareaAutosize } from '$lib/spells/textarea-autosize.svelte.js';
import Tooltip from '$lib/components/ui/tooltip.svelte'; import Tooltip from '$lib/components/ui/tooltip.svelte';
@ -51,6 +52,49 @@
}); });
const _autosize = new TextareaAutosize(); const _autosize = new TextareaAutosize();
function groupConversationsByTime(conversations: Doc<'conversations'>[]) {
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const sevenDays = 7 * oneDay;
const thirtyDays = 30 * oneDay;
const groups = {
today: [] as Doc<'conversations'>[],
yesterday: [] as Doc<'conversations'>[],
lastWeek: [] as Doc<'conversations'>[],
lastMonth: [] as Doc<'conversations'>[],
older: [] as Doc<'conversations'>[],
};
conversations.forEach((conversation) => {
const updatedAt = conversation.updated_at ?? 0;
const timeDiff = now - updatedAt;
if (timeDiff < oneDay) {
groups.today.push(conversation);
} else if (timeDiff < 2 * oneDay) {
groups.yesterday.push(conversation);
} else if (timeDiff < sevenDays) {
groups.lastWeek.push(conversation);
} else if (timeDiff < thirtyDays) {
groups.lastMonth.push(conversation);
} else {
groups.older.push(conversation);
}
});
return groups;
}
const groupedConversations = $derived(groupConversationsByTime(conversationsQuery.data ?? []));
const templateConversations = $derived([
{ key: 'today', label: 'Today', conversations: groupedConversations.today },
{ key: 'yesterday', label: 'Yesterday', conversations: groupedConversations.yesterday },
{ key: 'lastWeek', label: 'Last 7 days', conversations: groupedConversations.lastWeek },
{ key: 'lastMonth', label: 'Last 30 days', conversations: groupedConversations.lastMonth },
{ key: 'older', label: 'Older', conversations: groupedConversations.older },
]);
</script> </script>
<svelte:head> <svelte:head>
@ -71,37 +115,57 @@
New Chat New Chat
</a> </a>
</div> </div>
<div class="flex flex-1 flex-col overflow-y-auto py-2"> <div class="relative flex flex-1 flex-col">
{#each conversationsQuery.data ?? [] as conversation (conversation._id)} <div
<a class="from-sidebar pointer-events-none absolute top-0 right-0 left-0 z-10 h-4 bg-gradient-to-b to-transparent"
href={`/chat/${conversation._id}`} ></div>
class="group relative overflow-clip py-0.5 pr-2.5 text-left text-sm" <div class="flex flex-1 flex-col overflow-y-auto py-2">
> {#each templateConversations as group, index (group.key)}
<p class="group-hover:bg-sidebar-accent rounded-md py-1.5 pl-3"> {#if group.conversations.length > 0}
{conversation.title} <div class="px-2 py-1" class:mt-2={index > 0}>
</p> <h3 class="text-heading text-xs font-medium">{group.label}</h3>
<div </div>
class=" to-sidebar-accent pointer-events-none absolute inset-y-0 right-0 flex translate-x-full items-center gap-2 rounded-r-lg bg-gradient-to-r from-transparent pr-2 transition group-hover:pointer-events-auto group-hover:translate-0" {#each group.conversations as conversation (conversation._id)}
> {@const isActive = page.params.id === conversation._id}
<Tooltip> <a href={`/chat/${conversation._id}`} class="group py-0.5 pr-2.5 text-left text-sm">
{#snippet trigger(tooltip)} <div class="relative overflow-clip">
<button {...tooltip.trigger} class="hover:bg-muted rounded-md p-1"> <p
<PinIcon class="size-4" /> class={[
</button> ' rounded-lg py-2 pl-3',
{/snippet} isActive ? 'bg-sidebar-accent' : 'group-hover:bg-sidebar-accent ',
Pin thread ]}
</Tooltip> >
<Tooltip> <span>{conversation.title}</span>
{#snippet trigger(tooltip)} </p>
<button {...tooltip.trigger} class="hover:bg-muted rounded-md p-1"> <div
<XIcon class="size-4" /> class=" to-sidebar-accent pointer-events-none absolute inset-y-0.5 right-0 flex translate-x-full items-center gap-2 rounded-r-lg bg-gradient-to-r from-transparent pr-2 transition group-hover:pointer-events-auto group-hover:translate-0"
</button> >
{/snippet} <Tooltip>
Delete thread {#snippet trigger(tooltip)}
</Tooltip> <button {...tooltip.trigger} class="hover:bg-muted rounded-md p-1">
</div> <PinIcon class="size-4" />
</a> </button>
{/each} {/snippet}
Pin thread
</Tooltip>
<Tooltip>
{#snippet trigger(tooltip)}
<button {...tooltip.trigger} class="hover:bg-muted rounded-md p-1">
<XIcon class="size-4" />
</button>
{/snippet}
Delete thread
</Tooltip>
</div>
</div>
</a>
{/each}
{/if}
{/each}
</div>
<div
class="from-sidebar pointer-events-none absolute right-0 bottom-0 left-0 z-10 h-4 bg-gradient-to-t to-transparent"
></div>
</div> </div>
<div class="py-2"> <div class="py-2">
{#if data.session !== null} {#if data.session !== null}