diff --git a/apps/api/package.json b/apps/api/package.json index 023421e13..4ec4477db 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,7 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@electric-sql/client": "^1.3.1", + "@electric-sql/client": "1.4.0", "@linear/sdk": "^68.1.0", "@sentry/nextjs": "^10.32.1", "@superset/auth": "workspace:*", @@ -23,6 +23,7 @@ "@upstash/qstash": "^2.8.4", "@vercel/blob": "^2.0.0", "better-auth": "^1.4.9", + "date-fns": "^4.1.0", "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", "jose": "^6.1.3", diff --git a/apps/api/src/app/api/electric/[...path]/route.ts b/apps/api/src/app/api/electric/[...path]/route.ts index 2cb3967e4..847e3ca01 100644 --- a/apps/api/src/app/api/electric/[...path]/route.ts +++ b/apps/api/src/app/api/electric/[...path]/route.ts @@ -42,19 +42,18 @@ export async function GET(request: Request): Promise { originUrl.searchParams.set(`params[${index + 1}]`, String(value)); }); - const response = await fetch(originUrl.toString()); - - const headers = new Headers(); - response.headers.forEach((value, key) => { - const lower = key.toLowerCase(); - if (lower !== "content-encoding" && lower !== "content-length") { - headers.set(key, value); - } - }); + let response = await fetch(originUrl.toString()); + + if (response.headers.get("content-encoding")) { + const headers = new Headers(response.headers); + headers.delete("content-encoding"); + headers.delete("content-length"); + response = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); + return response; } diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index ba2b1d05f..6f14103be 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -3,6 +3,7 @@ import { members, organizations, repositories, + taskStatuses, tasks, users, } from "@superset/db/schema"; @@ -12,6 +13,7 @@ import { QueryBuilder } from "drizzle-orm/pg-core"; export type AllowedTable = | "tasks" + | "task_statuses" | "repositories" | "auth.members" | "auth.organizations" @@ -42,6 +44,9 @@ export async function buildWhereClause( case "tasks": return build(tasks, tasks.organizationId, organizationId); + case "task_statuses": + return build(taskStatuses, taskStatuses.organizationId, organizationId); + case "repositories": return build(repositories, repositories.organizationId, organizationId); diff --git a/apps/api/src/app/api/integrations/linear/callback/route.ts b/apps/api/src/app/api/integrations/linear/callback/route.ts index f41e4954d..7eebb8f2d 100644 --- a/apps/api/src/app/api/integrations/linear/callback/route.ts +++ b/apps/api/src/app/api/integrations/linear/callback/route.ts @@ -99,10 +99,9 @@ export async function GET(request: Request) { }, }); - const qstashBaseUrl = env.NEXT_PUBLIC_API_URL; try { await qstash.publishJSON({ - url: `${qstashBaseUrl}/api/integrations/linear/jobs/initial-sync`, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, body: { organizationId, creatorUserId: userId }, retries: 3, }); diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts index 1c2422749..f99ed9502 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts @@ -1,11 +1,17 @@ import { LinearClient } from "@linear/sdk"; import { buildConflictUpdateColumns, db } from "@superset/db"; -import { integrationConnections, tasks, users } from "@superset/db/schema"; +import { + integrationConnections, + taskStatuses, + tasks, + users, +} from "@superset/db/schema"; import { Receiver } from "@upstash/qstash"; import { and, eq, inArray } from "drizzle-orm"; import chunk from "lodash.chunk"; import { z } from "zod"; import { env } from "@/env"; +import { syncWorkflowStates } from "./syncWorkflowStates"; import { fetchAllIssues, mapIssueToTask } from "./utils"; const BATCH_SIZE = 100; @@ -28,11 +34,10 @@ export async function POST(request: Request) { return Response.json({ error: "Missing signature" }, { status: 401 }); } - const qstashBaseUrl = env.NEXT_PUBLIC_API_URL; const isValid = await receiver.verify({ body, signature, - url: `${qstashBaseUrl}/api/integrations/linear/jobs/initial-sync`, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/initial-sync`, }); if (!isValid) { @@ -68,6 +73,21 @@ async function performInitialSync( organizationId: string, creatorUserId: string, ) { + await syncWorkflowStates({ client, organizationId }); + + const statusByExternalId = new Map(); + const statuses = await db.query.taskStatuses.findMany({ + where: and( + eq(taskStatuses.organizationId, organizationId), + eq(taskStatuses.externalProvider, "linear"), + ), + }); + for (const status of statuses) { + if (status.externalId) { + statusByExternalId.set(status.externalId, status.id); + } + } + const issues = await fetchAllIssues(client); if (issues.length === 0) { @@ -90,7 +110,13 @@ async function performInitialSync( const userByEmail = new Map(matchedUsers.map((u) => [u.email, u.id])); const taskValues = issues.map((issue) => - mapIssueToTask(issue, organizationId, creatorUserId, userByEmail), + mapIssueToTask( + issue, + organizationId, + creatorUserId, + userByEmail, + statusByExternalId, + ), ); const batches = chunk(taskValues, BATCH_SIZE); @@ -100,15 +126,17 @@ async function performInitialSync( .insert(tasks) .values(batch) .onConflictDoUpdate({ - target: [tasks.externalProvider, tasks.externalId], + target: [ + tasks.organizationId, + tasks.externalProvider, + tasks.externalId, + ], set: { ...buildConflictUpdateColumns(tasks, [ "slug", "title", "description", - "status", - "statusColor", - "statusType", + "statusId", "priority", "assigneeId", "estimate", diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/syncWorkflowStates.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/syncWorkflowStates.ts new file mode 100644 index 000000000..f82b46bb3 --- /dev/null +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/syncWorkflowStates.ts @@ -0,0 +1,67 @@ +import type { LinearClient } from "@linear/sdk"; +import { buildConflictUpdateColumns } from "@superset/db"; +import { db } from "@superset/db/client"; +import { taskStatuses } from "@superset/db/schema"; +import { calculateProgressForStates } from "./utils"; + +export async function syncWorkflowStates({ + client, + organizationId, +}: { + client: LinearClient; + organizationId: string; +}): Promise { + const teams = await client.teams(); + + for (const team of teams.nodes) { + const states = await team.states(); + + const statesByType = new Map(); + for (const state of states.nodes) { + if (!statesByType.has(state.type)) { + statesByType.set(state.type, []); + } + statesByType.get(state.type)?.push(state); + } + + const startedStates = statesByType.get("started") || []; + const progressMap = calculateProgressForStates( + startedStates.map((s) => ({ name: s.name, position: s.position })), + ); + + const values = states.nodes.map((state) => ({ + organizationId, + name: state.name, + color: state.color, + type: state.type, + position: state.position, + progressPercent: + state.type === "started" ? (progressMap.get(state.name) ?? null) : null, + externalProvider: "linear" as const, + externalId: state.id, + })); + + if (values.length > 0) { + await db + .insert(taskStatuses) + .values(values) + .onConflictDoUpdate({ + target: [ + taskStatuses.organizationId, + taskStatuses.externalProvider, + taskStatuses.externalId, + ], + set: { + ...buildConflictUpdateColumns(taskStatuses, [ + "name", + "color", + "type", + "position", + "progressPercent", + ]), + updatedAt: new Date(), + }, + }); + } + } +} diff --git a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts index e86c77c10..f14271d85 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts @@ -1,5 +1,6 @@ import type { LinearClient } from "@linear/sdk"; import { mapPriorityFromLinear } from "@superset/trpc/integrations/linear"; +import { subMonths } from "date-fns"; export interface LinearIssue { id: string; @@ -9,11 +10,18 @@ export interface LinearIssue { priority: number; estimate: number | null; dueDate: string | null; + createdAt: string; url: string; startedAt: string | null; completedAt: string | null; assignee: { id: string; email: string } | null; - state: { id: string; name: string; color: string; type: string }; + state: { + id: string; + name: string; + color: string; + type: string; + position: number; + }; labels: { nodes: Array<{ id: string; name: string }> }; } @@ -24,6 +32,51 @@ interface IssuesQueryResponse { }; } +interface WorkflowStateWithPosition { + name: string; + position: number; +} + +/** + * Calculates progress percentage for "started" type workflow states + * using Linear's rendering formula: + * - 1 state: 50% + * - 2 states: [50%, 75%] + * - 3+ states: evenly spaced using (index + 1) / (total + 1) + */ +export function calculateProgressForStates( + states: WorkflowStateWithPosition[], +): Map { + const progressMap = new Map(); + + if (states.length === 0) { + return progressMap; + } + + const sorted = [...states].sort((a, b) => a.position - b.position); + + const total = sorted.length; + + for (let i = 0; i < total; i++) { + const state = sorted[i]; + if (!state) continue; + + let progress: number; + + if (total === 1) { + progress = 50; + } else if (total === 2) { + progress = i === 0 ? 50 : 75; + } else { + progress = ((i + 1) / (total + 1)) * 100; + } + + progressMap.set(state.name, Math.round(progress)); + } + + return progressMap; +} + const ISSUES_QUERY = ` query Issues($first: Int!, $after: String, $filter: IssueFilter) { issues(first: $first, after: $after, filter: $filter) { @@ -39,6 +92,7 @@ const ISSUES_QUERY = ` priority estimate dueDate + createdAt url startedAt completedAt @@ -51,6 +105,7 @@ const ISSUES_QUERY = ` name color type + position } labels { nodes { @@ -68,6 +123,7 @@ export async function fetchAllIssues( ): Promise { const allIssues: LinearIssue[] = []; let cursor: string | undefined; + const threeMonthsAgo = subMonths(new Date(), 3); do { const response = await client.client.request< @@ -76,7 +132,7 @@ export async function fetchAllIssues( >(ISSUES_QUERY, { first: 100, after: cursor, - filter: { state: { type: { nin: ["canceled", "completed"] } } }, + filter: { updatedAt: { gte: threeMonthsAgo.toISOString() } }, }); allIssues.push(...response.issues.nodes); cursor = @@ -93,20 +149,24 @@ export function mapIssueToTask( organizationId: string, creatorId: string, userByEmail: Map, + statusByExternalId: Map, ) { const assigneeId = issue.assignee?.email ? (userByEmail.get(issue.assignee.email) ?? null) : null; + const statusId = statusByExternalId.get(issue.state.id); + if (!statusId) { + throw new Error(`Status not found for state ${issue.state.id}`); + } + return { organizationId, creatorId, slug: issue.identifier, title: issue.title, description: issue.description, - status: issue.state.name, - statusColor: issue.state.color, - statusType: issue.state.type, + statusId, priority: mapPriorityFromLinear(issue.priority), assigneeId, estimate: issue.estimate, @@ -114,6 +174,7 @@ export function mapIssueToTask( labels: issue.labels.nodes.map((l) => l.name), startedAt: issue.startedAt ? new Date(issue.startedAt) : null, completedAt: issue.completedAt ? new Date(issue.completedAt) : null, + createdAt: new Date(issue.createdAt), externalProvider: "linear" as const, externalId: issue.id, externalKey: issue.identifier, diff --git a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts index e1c3680af..a06e0e90d 100644 --- a/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts +++ b/apps/api/src/app/api/integrations/linear/jobs/sync-task/route.ts @@ -1,7 +1,11 @@ import type { LinearClient, WorkflowState } from "@linear/sdk"; import { db } from "@superset/db/client"; import type { LinearConfig, SelectTask } from "@superset/db/schema"; -import { integrationConnections, tasks } from "@superset/db/schema"; +import { + integrationConnections, + taskStatuses, + tasks, +} from "@superset/db/schema"; import { getLinearClient, mapPriorityToLinear, @@ -69,7 +73,15 @@ async function syncTaskToLinear( } try { - const stateId = await findLinearState(client, teamId, task.status); + const taskStatus = await db.query.taskStatuses.findFirst({ + where: eq(taskStatuses.id, task.statusId), + }); + + if (!taskStatus) { + return { success: false, error: "Task status not found" }; + } + + const stateId = await findLinearState(client, teamId, taskStatus.name); if (task.externalProvider === "linear" && task.externalId) { const result = await client.updateIssue(task.externalId, { @@ -164,11 +176,10 @@ export async function POST(request: Request) { return Response.json({ error: "Missing signature" }, { status: 401 }); } - const qstashBaseUrl = env.NEXT_PUBLIC_API_URL; const isValid = await receiver.verify({ body, signature, - url: `${qstashBaseUrl}/api/integrations/linear/jobs/sync-task`, + url: `${env.NEXT_PUBLIC_API_URL}/api/integrations/linear/jobs/sync-task`, }); if (!isValid) { diff --git a/apps/api/src/app/api/integrations/linear/webhook/route.ts b/apps/api/src/app/api/integrations/linear/webhook/route.ts index d820c6a62..cae9af0a5 100644 --- a/apps/api/src/app/api/integrations/linear/webhook/route.ts +++ b/apps/api/src/app/api/integrations/linear/webhook/route.ts @@ -7,6 +7,7 @@ import { db } from "@superset/db/client"; import type { SelectIntegrationConnection } from "@superset/db/schema"; import { integrationConnections, + taskStatuses, tasks, users, webhookEvents, @@ -58,8 +59,10 @@ export async function POST(request: Request) { } try { + let status: "processed" | "skipped" = "processed"; + if (payload.type === "Issue") { - await processIssueEvent( + status = await processIssueEvent( payload as EntityWebhookPayloadWithIssueData, connection, ); @@ -67,7 +70,10 @@ export async function POST(request: Request) { await db .update(webhookEvents) - .set({ status: "processed", processedAt: new Date() }) + .set({ + status, + processedAt: new Date(), + }) .where(eq(webhookEvents.id, webhookEvent.id)); return Response.json({ success: true }); @@ -88,10 +94,28 @@ export async function POST(request: Request) { async function processIssueEvent( payload: EntityWebhookPayloadWithIssueData, connection: SelectIntegrationConnection, -) { +): Promise<"processed" | "skipped"> { const issue = payload.data; if (payload.action === "create" || payload.action === "update") { + const taskStatus = await db.query.taskStatuses.findFirst({ + where: and( + eq(taskStatuses.organizationId, connection.organizationId), + eq(taskStatuses.externalProvider, "linear"), + eq(taskStatuses.externalId, issue.state.id), + ), + }); + + if (!taskStatus) { + // TODO(SUPER-237): Handle new workflow states in webhooks by triggering syncWorkflowStates + // Currently webhooks silently fail when Linear has new statuses that aren't synced yet. + // Should either: (1) trigger workflow state sync and retry, (2) queue for retry, or (3) keep periodic sync only + console.warn( + `[webhook] Status not found for state ${issue.state.id}, skipping update`, + ); + return "skipped"; + } + let assigneeId: string | null = null; if (issue.assignee?.email) { const matchedUser = await db.query.users.findFirst({ @@ -104,9 +128,7 @@ async function processIssueEvent( slug: issue.identifier, title: issue.title, description: issue.description ?? null, - status: issue.state.name, - statusColor: issue.state.color, - statusType: issue.state.type, + statusId: taskStatus.id, priority: mapPriorityFromLinear(issue.priority), assigneeId, estimate: issue.estimate ?? null, @@ -127,9 +149,14 @@ async function processIssueEvent( ...taskData, organizationId: connection.organizationId, creatorId: connection.connectedByUserId, + createdAt: new Date(issue.createdAt), }) .onConflictDoUpdate({ - target: [tasks.externalProvider, tasks.externalId], + target: [ + tasks.organizationId, + tasks.externalProvider, + tasks.externalId, + ], set: { ...taskData, syncError: null }, }); } else if (payload.action === "remove") { @@ -143,4 +170,6 @@ async function processIssueEvent( ), ); } + + return "processed"; } diff --git a/apps/desktop/docs/SEMANTIC_SEARCH_PLAN.md b/apps/desktop/docs/SEMANTIC_SEARCH_PLAN.md new file mode 100644 index 000000000..93c880e50 --- /dev/null +++ b/apps/desktop/docs/SEMANTIC_SEARCH_PLAN.md @@ -0,0 +1,932 @@ +# Semantic Search with Embeddings - Local-First Architecture + +## Overview + +Implement semantic search for tasks using text embeddings, with a **local-first architecture** that keeps the embedding model in the desktop app's main process. This enables offline search while maintaining snappy UI updates through fire-and-forget embedding computation. + +## Architecture + +### High-Level Flow + +``` +Server (Linear Sync): + Linear task → generate embedding → store in DB + ↓ + ElectricSQL syncs task + embedding to desktop + ↓ +Desktop has embedding ready for search + +Desktop (User-Created Tasks): + User creates task → insert to TanStack DB (no embedding yet) + ↓ + Fire-and-forget tRPC call → main process computes embedding + ↓ + Main returns embedding → update TanStack DB + ↓ + ElectricSQL syncs back to server + +Desktop (Search): + User types "auth bug" → tRPC call to main process + ↓ + Main generates query embedding → returns to renderer + ↓ + Renderer: cosine similarity against task embeddings + ↓ + Sorted results displayed +``` + +### Key Design Decisions + +1. **Model Location**: Main process (shared across all renderer operations, doesn't block UI) +2. **Task Embeddings**: Generated server-side during Linear sync, locally for user-created tasks +3. **Query Embeddings**: Generated on-demand in main process via tRPC +4. **Search Execution**: In-memory cosine similarity in renderer (fast, no IPC overhead) +5. **Optimistic Updates**: Tasks created without embeddings, embedding added async (fire-and-forget) + +--- + +## Current State + +### Database Schema +- ❌ No `embedding` column on tasks table +- ✅ Tasks table has: id, title, description, slug, statusId, priority, etc. +- ✅ Uses jsonb for labels (can use same for embeddings) + +### API Sync +- ✅ Linear sync working (`performInitialSync()`) +- ✅ Batch inserts 100 tasks at a time +- ❌ No embedding generation during sync + +### Desktop App +- ✅ tRPC setup with `ipcLink` for renderer-main communication +- ✅ Collections using ElectricSQL for real-time sync +- ✅ Update pattern: `collections.tasks.update(id, draft => { ... })` +- ❌ No embedding model loaded +- ❌ No tRPC endpoint for embedding computation + +### Current Search +- ✅ TanStack Table globalFilter +- ✅ Simple substring matching on title + slug +- ❌ No semantic search +- ❌ No relevance ranking + +--- + +## Implementation Plan + +### Phase 1: Database Schema Update + +**Goal**: Add embedding storage to tasks table + +#### Step 1.1: Update Schema +**File**: `packages/db/src/schema/schema.ts` + +Add embedding column to tasks table: +```typescript +export const tasks = pgTable( + "tasks", + { + // ... existing fields + + // NEW: Text embedding for semantic search + embedding: jsonb("embedding").$type(), + + // ... rest of fields + }, + // ... indexes +); +``` + +#### Step 1.2: Generate and Push Migration + +**Note**: You will handle generating and pushing the migration yourself. + +After updating the schema in Step 1.1, you should: +1. Spin up a new Neon branch +2. Update `.env` files to point at the Neon branch locally +3. Generate migration: `pnpm drizzle-kit generate --name="add_task_embeddings"` +4. Review and push the migration + +**Verification**: +```sql +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name = 'tasks' AND column_name = 'embedding'; +``` + +--- + +### Phase 2: API - Embedding Generation During Sync + +**Goal**: Generate embeddings server-side during Linear sync + +#### Step 2.1: Install Dependencies +```bash +cd apps/api +bun add @xenova/transformers +``` + +#### Step 2.2: Create Embedding Utility +**File**: `apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils/embeddings.ts` (NEW) + +```typescript +import { pipeline } from '@xenova/transformers'; + +let embedder: Awaited> | null = null; + +/** + * Lazily loads the embedding model (downloads ~90MB once, then caches) + */ +export async function getEmbedder() { + if (!embedder) { + console.log("[embeddings] Loading model: Xenova/all-MiniLM-L6-v2"); + embedder = await pipeline( + 'feature-extraction', + 'Xenova/all-MiniLM-L6-v2' + ); + console.log("[embeddings] Model loaded successfully"); + } + return embedder; +} + +/** + * Generates embedding for a task (combines title + description + labels) + */ +export async function generateTaskEmbedding( + title: string, + description: string | null, + labels: string[] = [] +): Promise { + const model = await getEmbedder(); + + // Combine title, description, and labels for richer embeddings + const parts = [title, description, ...labels].filter(Boolean); + const text = parts.join(' '); + + const result = await model(text, { + pooling: 'mean', + normalize: true, + }); + + return Array.from(result.data); +} +``` + +#### Step 2.3: Update mapIssueToTask +**File**: `apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts` + +Update signature to accept embeddings: +```typescript +export function mapIssueToTask( + issue: LinearIssue, + organizationId: string, + creatorId: string, + userByEmail: Map, + statusByExternalId: Map, + embedding: number[], // NEW parameter +) { + return { + // ... existing fields + embedding, // NEW field + // ... rest of fields + }; +} +``` + +#### Step 2.4: Update performInitialSync +**File**: `apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts` + +Generate embeddings using **batch processing** (more efficient than Promise.all): +```typescript +async function performInitialSync( + client: LinearClient, + organizationId: string, + creatorUserId: string, +) { + // ... existing workflow state sync + + // Fetch issues + const issues = await fetchAllIssues(client); + + // Generate embeddings in BATCH (more efficient than individual calls) + console.log(`[initial-sync] Generating embeddings for ${issues.length} issues`); + + const embedder = await getEmbedder(); + + // Prepare all texts at once + const texts = issues.map(issue => { + const parts = [ + issue.title, + issue.description, + ...issue.labels.nodes.map(l => l.name) + ].filter(Boolean); + return parts.join(' '); + }); + + // Single batched call (faster than individual promises) + const result = await embedder(texts, { + pooling: 'mean', + normalize: true, + }); + + // Convert to array of embeddings + const embeddings: number[][] = []; + const embeddingDim = 384; // all-MiniLM-L6-v2 dimension + for (let i = 0; i < issues.length; i++) { + const start = i * embeddingDim; + const end = start + embeddingDim; + embeddings.push(Array.from(result.data.slice(start, end))); + } + + console.log("[initial-sync] Embeddings generated"); + + // Map issues to tasks WITH embeddings + const mappedTasks = issues.map((issue, index) => + mapIssueToTask( + issue, + organizationId, + creatorUserId, + userByEmail, + statusByExternalId, + embeddings[index] // Pass pre-computed embedding + ) + ); + + // ... rest of batch insert logic +} +``` + +**Why batch?** Transformers.js processes batches much faster than individual calls - ~2-3x speedup for 100+ tasks. + +--- + +### Phase 3: Desktop Main Process - Embedding Model & tRPC Endpoint + +**Goal**: Load embedding model in main process, expose tRPC endpoint for renderer + +#### Step 3.1: Install Dependencies +```bash +cd apps/desktop +bun add @xenova/transformers +``` + +#### Step 3.2: Create Embedding Service +**File**: `apps/desktop/src/main/lib/embeddings.ts` (NEW) + +```typescript +import { pipeline } from '@xenova/transformers'; + +let embedder: Awaited> | null = null; + +/** + * Initialize the embedding model (call on app startup) + * Downloads ~90MB model on first run, then caches locally + */ +export async function initEmbeddings() { + if (!embedder) { + console.log("[embeddings] Loading model: Xenova/all-MiniLM-L6-v2"); + embedder = await pipeline( + 'feature-extraction', + 'Xenova/all-MiniLM-L6-v2' + ); + console.log("[embeddings] Model loaded and cached"); + } + return embedder; +} + +/** + * Generate embedding for text (task or query) + */ +export async function generateEmbedding(text: string): Promise { + const model = await initEmbeddings(); + + const result = await model(text, { + pooling: 'mean', + normalize: true, + }); + + return Array.from(result.data); +} +``` + +#### Step 3.3: Load Model on Startup +**File**: `apps/desktop/src/main/index.ts` + +Add to app initialization: +```typescript +import { initEmbeddings } from './lib/embeddings'; + +app.whenReady().then(async () => { + // ... existing initialization + + // Pre-load embedding model (async, doesn't block window creation) + console.log("[main] Pre-loading embedding model..."); + initEmbeddings().catch(error => { + console.error("[main] Failed to load embedding model:", error); + }); + + // ... create window +}); +``` + +#### Step 3.4: Create tRPC Router for Embeddings +**File**: `apps/desktop/src/lib/trpc/routers/embeddings/index.ts` (NEW) + +```typescript +import { z } from 'zod'; +import { router, publicProcedure } from '../../index'; +import { generateEmbedding } from '../../../../main/lib/embeddings'; + +export const createEmbeddingsRouter = () => { + return router({ + /** + * Generate embedding for a text string (task or search query) + */ + compute: publicProcedure + .input(z.object({ + text: z.string().min(1), + })) + .mutation(async ({ input }) => { + const embedding = await generateEmbedding(input.text); + return { embedding }; + }), + }); +}; +``` + +#### Step 3.5: Register Router +**File**: `apps/desktop/src/lib/trpc/index.ts` + +Add embeddings router: +```typescript +import { createEmbeddingsRouter } from './routers/embeddings'; + +export const appRouter = router({ + // ... existing routers + embeddings: createEmbeddingsRouter(), +}); + +export type AppRouter = typeof appRouter; +``` + +--- + +### Phase 4: Desktop Renderer - Fire-and-Forget Embedding Updates + +**Goal**: Compute embeddings for user-created/updated tasks asynchronously + +#### Step 4.1: Create Embedding Helper +**File**: `apps/desktop/src/renderer/hooks/useTaskEmbedding.ts` (NEW) + +```typescript +import { useCallback } from 'react'; +import { trpc } from '../../lib/trpc'; +import { useCollections } from '../contexts/CollectionsProvider'; + +/** + * Hook to compute and update task embeddings asynchronously + */ +export function useTaskEmbedding() { + const collections = useCollections(); + const computeEmbedding = trpc.embeddings.compute.useMutation(); + + /** + * Fire-and-forget: Compute embedding and update task + * Returns immediately, embedding updates in background + */ + const updateTaskEmbedding = useCallback(( + taskId: string, + title: string, + description: string | null, + labels: string[] = [] + ) => { + // Combine title, description, and labels + const parts = [title, description, ...labels].filter(Boolean); + const text = parts.join(' '); + + // Fire and forget - don't await + computeEmbedding.mutateAsync({ text }) + .then(({ embedding }) => { + collections.tasks.update(taskId, (draft) => { + draft.embedding = embedding; + }); + }) + .catch(error => { + console.error('[useTaskEmbedding] Failed to compute embedding:', error); + }); + }, [collections, computeEmbedding]); + + return { updateTaskEmbedding }; +} +``` + +#### Step 4.2: Update Task Creation Pattern +**File**: `apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useCreateTask.ts` (NEW or UPDATE if exists) + +```typescript +import { useCollections } from '../../../contexts/CollectionsProvider'; +import { useTaskEmbedding } from '../../../../hooks/useTaskEmbedding'; + +export function useCreateTask() { + const collections = useCollections(); + const { updateTaskEmbedding } = useTaskEmbedding(); + + const createTask = (taskData: { title: string; description: string | null; /* ... */ }) => { + // 1. Optimistic insert - immediate UI feedback + const taskId = crypto.randomUUID(); + collections.tasks.insert({ + id: taskId, + ...taskData, + embedding: null, // No embedding yet + }); + + // 2. Fire-and-forget embedding computation + updateTaskEmbedding(taskId, taskData.title, taskData.description); + + // 3. Return immediately + return taskId; + }; + + return { createTask }; +} +``` + +**Pattern for updates**: Similar approach - update fields immediately, recompute embedding async. + +--- + +### Phase 5: Desktop Renderer - Semantic Search + +**Goal**: Replace substring search with semantic search using embeddings + +#### Step 5.1: Create Cosine Similarity Utility +**File**: `apps/desktop/src/renderer/utils/embeddings.ts` (NEW) + +```typescript +/** + * Compute cosine similarity between two embedding vectors + * Returns value between -1 and 1 (higher = more similar) + */ +export function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length) { + throw new Error('Vectors must have same length'); + } + + let dotProduct = 0; + let magnitudeA = 0; + let magnitudeB = 0; + + for (let i = 0; i < a.length; i++) { + dotProduct += a[i] * b[i]; + magnitudeA += a[i] * a[i]; + magnitudeB += b[i] * b[i]; + } + + magnitudeA = Math.sqrt(magnitudeA); + magnitudeB = Math.sqrt(magnitudeB); + + if (magnitudeA === 0 || magnitudeB === 0) { + return 0; + } + + return dotProduct / (magnitudeA * magnitudeB); +} +``` + +#### Step 5.2: Create Semantic Search Hook with Caching & Hybrid Search +**File**: `apps/desktop/src/renderer/hooks/useSemanticSearch.ts` (NEW) + +```typescript +import { useState, useCallback, useRef } from 'react'; +import { trpc } from '../lib/trpc'; +import { cosineSimilarity } from '../utils/embeddings'; + +interface Task { + id: string; + title: string; + slug: string; + embedding?: number[] | null; +} + +interface SearchResult { + task: T; + score: number; +} + +/** + * Simple keyword matching score (0-1) + * Used for hybrid search to catch exact matches + */ +function keywordScore(query: string, task: Task): number { + const lowerQuery = query.toLowerCase(); + const titleMatch = task.title.toLowerCase().includes(lowerQuery); + const slugMatch = task.slug.toLowerCase().includes(lowerQuery); + + // Exact slug match = highest score + if (task.slug.toLowerCase() === lowerQuery) return 1.0; + // Slug contains = high score + if (slugMatch) return 0.8; + // Title contains = medium score + if (titleMatch) return 0.6; + // No match + return 0; +} + +/** + * Hook for hybrid semantic + keyword search with query caching + */ +export function useSemanticSearch() { + const [isSearching, setIsSearching] = useState(false); + const computeEmbedding = trpc.embeddings.compute.useMutation(); + + // Cache query embeddings to avoid recomputing + const queryCache = useRef(new Map()); + + /** + * Perform hybrid search (semantic + keyword) + * Returns tasks sorted by relevance (highest score first) + */ + const search = useCallback(async ( + query: string, + tasks: T[] + ): Promise[]> => { + if (!query.trim()) { + return tasks.map(task => ({ task, score: 1 })); + } + + setIsSearching(true); + + try { + // 1. Get query embedding (from cache or generate) + let queryEmbedding = queryCache.current.get(query); + + if (!queryEmbedding) { + const result = await computeEmbedding.mutateAsync({ text: query }); + queryEmbedding = result.embedding; + queryCache.current.set(query, queryEmbedding); + + // Limit cache size to 50 queries + if (queryCache.current.size > 50) { + const firstKey = queryCache.current.keys().next().value; + queryCache.current.delete(firstKey); + } + } + + // 2. Compute hybrid scores (70% semantic + 30% keyword) + const results = tasks + .filter(task => task.embedding && task.embedding.length > 0) + .map(task => { + const semanticScore = cosineSimilarity(queryEmbedding!, task.embedding!); + const kwScore = keywordScore(query, task); + + // Hybrid: 70% semantic, 30% keyword + const hybridScore = (0.7 * semanticScore) + (0.3 * kwScore); + + return { task, score: hybridScore }; + }) + .sort((a, b) => b.score - a.score); + + return results; + } catch (error) { + console.error('[useSemanticSearch] Search failed:', error); + + // Fallback to keyword-only search + return tasks + .map(task => ({ task, score: keywordScore(query, task) })) + .filter(r => r.score > 0) + .sort((a, b) => b.score - a.score); + } finally { + setIsSearching(false); + } + }, [computeEmbedding]); + + return { search, isSearching }; +} +``` + +#### Step 5.3: Update useTasksTable to Use Semantic Search +**File**: `apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx` + +Replace globalFilter with semantic search: + +```typescript +import { useSemanticSearch } from '../../../../../hooks/useSemanticSearch'; + +export function useTasksTable({ filterTab, searchQuery }: UseTasksTableParams) { + // ... existing code + + const { search, isSearching } = useSemanticSearch(); + const [searchResults, setSearchResults] = useState>(new Map()); + + // Debounced semantic search + useEffect(() => { + if (!searchQuery.trim()) { + setSearchResults(new Map()); + return; + } + + const timer = setTimeout(async () => { + const results = await search(searchQuery, data); + const scoreMap = new Map( + results.map(r => [r.task.id, r.score]) + ); + setSearchResults(scoreMap); + }, 300); // 300ms debounce + + return () => clearTimeout(timer); + }, [searchQuery, data, search]); + + // Sort by semantic relevance when searching + const sortedData = useMemo(() => { + if (searchResults.size === 0) { + return data; + } + + // Filter by minimum similarity threshold + sort by score + return [...data] + .filter(task => { + const score = searchResults.get(task.id); + return score !== undefined && score > 0.3; // 30% similarity threshold + }) + .sort((a, b) => { + const scoreA = searchResults.get(a.id) || 0; + const scoreB = searchResults.get(b.id) || 0; + return scoreB - scoreA; + }); + }, [data, searchResults]); + + // Use sortedData for table instead of data + const table = useReactTable({ + data: sortedData, // Changed from 'data' + columns, + // ... rest of config + // REMOVE: globalFilter, globalFilterFn (no longer needed) + }); + + return { table, isLoading: isLoading || isSearching, slugColumnWidth }; +} +``` + +#### Step 5.4: Add Search Feedback UI +**File**: `apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTopBar/TasksTopBar.tsx` + +Add loading indicator: +```typescript +interface TasksTopBarProps { + // ... existing + isSearching?: boolean; // NEW +} + +export function TasksTopBar({ + currentTab, + onTabChange, + searchQuery, + onSearchChange, + isSearching = false // NEW +}: TasksTopBarProps) { + return ( +
+ {/* ... tabs */} + +
+ + onSearchChange(e.target.value)} + className="h-8 pl-9 pr-3 text-sm bg-muted/50 border-0 focus-visible:ring-1" + /> + {isSearching && ( +
+
+
+ )} +
+
+ ); +} +``` + +--- + +## Critical Files Summary + +### New Files to Create: + +**API:** +1. `apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils/embeddings.ts` - Server-side embedding generation + +**Desktop Main:** +2. `apps/desktop/src/main/lib/embeddings.ts` - Main process embedding model +3. `apps/desktop/src/lib/trpc/routers/embeddings/index.ts` - tRPC router for embeddings + +**Desktop Renderer:** +4. `apps/desktop/src/renderer/hooks/useTaskEmbedding.ts` - Fire-and-forget embedding updates +5. `apps/desktop/src/renderer/hooks/useSemanticSearch.ts` - Semantic search hook +6. `apps/desktop/src/renderer/hooks/useCreateTask.ts` - Task creation with embeddings +7. `apps/desktop/src/renderer/utils/embeddings.ts` - Cosine similarity utility + +### Files to Modify: + +**Database:** +1. `packages/db/src/schema/schema.ts` - Add embedding column + +**API:** +2. `apps/api/src/app/api/integrations/linear/jobs/initial-sync/utils.ts` - Update mapIssueToTask signature +3. `apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts` - Generate embeddings during sync + +**Desktop Main:** +4. `apps/desktop/src/main/index.ts` - Load embedding model on startup +5. `apps/desktop/src/lib/trpc/index.ts` - Register embeddings router + +**Desktop Renderer:** +6. `apps/desktop/src/renderer/screens/main/components/TasksView/hooks/useTasksTable/useTasksTable.tsx` - Replace globalFilter with semantic search +7. `apps/desktop/src/renderer/screens/main/components/TasksView/components/TasksTopBar/TasksTopBar.tsx` - Add search loading indicator +8. `apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx` - Pass isSearching prop + +--- + +## Testing & Verification Plan + +### 1. Schema Verification +```sql +-- Verify embedding column exists +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name = 'tasks' AND column_name = 'embedding'; + +-- Should return: embedding | jsonb +``` + +### 2. API Embedding Generation +```bash +# Trigger Linear sync +# Check database for embeddings + +SELECT + slug, + title, + jsonb_array_length(embedding) as embedding_dimensions +FROM tasks +WHERE external_provider = 'linear' +LIMIT 5; + +# Should return ~384 dimensions for all tasks +``` + +### 3. Desktop Model Loading +```bash +# Start desktop app, check logs +# Should see: "[embeddings] Loading model: Xenova/all-MiniLM-L6-v2" +# Should see: "[embeddings] Model loaded and cached" + +# First load: ~5-10 seconds (downloads model) +# Subsequent loads: <1 second (uses cache) +``` + +### 4. Task Creation with Embeddings +```javascript +// Create a test task in desktop app +// Check TanStack DB: +// 1. Task appears immediately (embedding: null) +// 2. After ~200-500ms, embedding populates +// 3. Verify embedding has 384 dimensions +``` + +### 5. Semantic Search Testing + +**Test queries:** +- "authentication bug" → should match tasks with "login", "auth", "user access" +- "performance issue" → should match "slow", "latency", "optimization" +- "database migration" → should match "schema", "SQL", "data" + +**Expected behavior:** +- Search triggers in 300ms (debounce) +- Spinner shows while generating query embedding +- Results sorted by relevance +- Tasks with similarity < 0.3 filtered out + +### 6. Edge Cases + +**No embeddings:** +- Create task → immediately search before embedding computed +- Should gracefully exclude from results + +**Empty search:** +- Clear search box → should show all tasks +- No API calls made + +**Offline:** +- Disconnect network → create task +- Embedding computation should fail gracefully +- Task still usable, just not semantically searchable + +**Model load failure:** +- Simulate model download failure +- Should log error, app should still function +- Search falls back to showing all tasks + +--- + +## Performance Considerations + +### Model Size +- **all-MiniLM-L6-v2**: ~90MB download (one-time) +- Cached in: `~/.cache/huggingface/` (or OS equivalent) +- Memory footprint: ~200-300MB when loaded + +### Embedding Generation +- **Single task**: ~50-200ms +- **Batch (100 tasks)**: ~2-5 seconds (parallel processing) +- **Search query**: ~50-200ms + +### Cosine Similarity +- **100 tasks**: <5ms (pure JavaScript math) +- **1000 tasks**: <50ms +- Scales linearly, no DB queries + +### Network Impact +- **Initial model download**: ~90MB (one-time) +- **After cache**: 0 bytes (fully local) +- **Search**: 0 bytes (no API calls) + +--- + +## Dependencies + +### New Dependencies + +**API:** +```json +{ + "@xenova/transformers": "^2.17.1" +} +``` + +**Desktop:** +```json +{ + "@xenova/transformers": "^2.17.1" +} +``` + +**No other dependencies needed** - uses existing: +- tRPC (already set up) +- Electric SQL (already syncing) +- TanStack Table (already used for display) + +--- + +## Rollout Strategy + +### Phase 1: Server-Side Only (Low Risk) +1. Deploy schema change (add embedding column) +2. Deploy API with embedding generation +3. Re-sync Linear tasks +4. Verify embeddings in database + +### Phase 2: Desktop Read-Only (Testing) +1. Deploy desktop with model loading + tRPC endpoint +2. Test embedding generation for queries +3. Don't wire up to UI yet +4. Monitor performance, model load times + +### Phase 3: Full Rollout +1. Wire up semantic search in UI +2. Monitor search quality +3. Collect feedback +4. Tune similarity threshold if needed + +### Rollback Plan +- Embedding column nullable → can remove feature without breaking +- Old globalFilter code still exists → revert UI changes +- Model doesn't auto-load → disable via feature flag + +--- + +## Future Enhancements + +### Short Term +- [ ] Hybrid search: Combine semantic + keyword matching +- [ ] Search result highlighting +- [ ] Show similarity scores in UI (debugging) +- [ ] Tune similarity threshold (currently 0.3) + +### Medium Term +- [ ] Multi-field search weights (title > description) +- [ ] Batch re-embedding on model version changes +- [ ] Background job: Fill missing embeddings +- [ ] Search analytics (track query quality) + +### Long Term +- [ ] Upgrade to larger model (better quality, slower) +- [ ] Custom fine-tuned model for task domain +- [ ] Search across comments, attachments +- [ ] Semantic suggestions ("Similar tasks") + +--- + +## Open Questions + +None - architecture is fully specified and ready for implementation. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 738586d11..e278afac6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -34,7 +34,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@electric-sql/client": "^1.3.1", + "@electric-sql/client": "1.4.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", @@ -50,6 +50,7 @@ "@tanstack/electric-db-collection": "^0.2.20", "@tanstack/react-db": "^0.1.60", "@tanstack/react-query": "^5.90.10", + "@tanstack/react-table": "^8.21.3", "@trpc/client": "^11.7.1", "@trpc/react-query": "^11.7.1", "@trpc/server": "^11.7.1", @@ -82,6 +83,7 @@ "fast-glob": "^3.3.3", "file-uri-to-path": "^1.0.0", "framer-motion": "^12.23.26", + "fuse.js": "^7.1.0", "http-proxy": "^1.18.1", "idb": "^8.0.3", "idb-keyval": "^6.2.2", diff --git a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts index b8901878c..eb51e3331 100644 --- a/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/contexts/CollectionsProvider/collections.ts @@ -4,6 +4,7 @@ import type { SelectOrganization, SelectRepository, SelectTask, + SelectTaskStatus, SelectUser, } from "@superset/db/schema"; import type { AppRouter } from "@superset/trpc"; @@ -19,6 +20,7 @@ const electricUrl = `${env.NEXT_PUBLIC_API_URL}/api/electric/v1/shape`; interface OrgCollections { tasks: Collection; + taskStatuses: Collection; repositories: Collection; members: Collection; users: Collection; @@ -56,7 +58,7 @@ function createOrgCollections( url: electricUrl, params: { table: "tasks", - organization: organizationId, + organizationId, }, headers, columnMapper, @@ -68,8 +70,11 @@ function createOrgCollections( return { txid: result.txid }; }, onUpdate: async ({ transaction }) => { - const { modified } = transaction.mutations[0]; - const result = await apiClient.task.update.mutate(modified); + const { original, changes } = transaction.mutations[0]; + const result = await apiClient.task.update.mutate({ + ...changes, + id: original.id, + }); return { txid: result.txid }; }, onDelete: async ({ transaction }) => { @@ -80,6 +85,22 @@ function createOrgCollections( }), ); + const taskStatuses = createCollection( + electricCollectionOptions({ + id: `task_statuses-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "task_statuses", + organizationId, + }, + headers, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + const repositories = createCollection( electricCollectionOptions({ id: `repositories-${organizationId}`, @@ -87,7 +108,7 @@ function createOrgCollections( url: electricUrl, params: { table: "repositories", - organization: organizationId, + organizationId, }, headers, columnMapper, @@ -113,7 +134,7 @@ function createOrgCollections( url: electricUrl, params: { table: "auth.members", - organization: organizationId, + organizationId, }, headers, columnMapper, @@ -129,7 +150,7 @@ function createOrgCollections( url: electricUrl, params: { table: "auth.users", - organization: organizationId, + organizationId, }, headers, columnMapper, @@ -138,7 +159,7 @@ function createOrgCollections( }), ); - return { tasks, repositories, members, users }; + return { tasks, taskStatuses, repositories, members, users }; } function getOrCreateOrganizationsCollection( diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx index 443095f80..d97507614 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/AccountSettings/AccountSettings.tsx @@ -1,5 +1,4 @@ -import { getInitials } from "@superset/shared/names"; -import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Avatar } from "@superset/ui/atoms/Avatar"; import { Button } from "@superset/ui/button"; import { Skeleton } from "@superset/ui/skeleton"; import { toast } from "@superset/ui/sonner"; @@ -13,8 +12,6 @@ export function AccountSettings() { const signOut = () => signOutMutation.mutate(); - const initials = getInitials(user?.name, user?.email); - return (
@@ -39,12 +36,7 @@ export function AccountSettings() { ) : user ? ( <> - - - - {initials || "?"} - - +

{user.name}

{user.email}

diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/TeamSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/TeamSettings.tsx index 51be9767c..245c85e4e 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/TeamSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/TeamSettings.tsx @@ -1,5 +1,4 @@ -import { getInitials } from "@superset/shared/names"; -import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Avatar } from "@superset/ui/atoms/Avatar"; import { Badge } from "@superset/ui/badge"; import { Skeleton } from "@superset/ui/skeleton"; import { @@ -10,6 +9,7 @@ import { TableHeader, TableRow, } from "@superset/ui/table"; +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useAuth } from "renderer/contexts/AuthProvider"; import { useCollections } from "renderer/contexts/CollectionsProvider"; @@ -20,55 +20,37 @@ export function TeamSettings() { const { session } = useAuth(); const collections = useCollections(); - const { data: membersData, isLoading: isLoadingMembers } = useLiveQuery( - (q) => q.from({ members: collections.members }), + const { data: membersData, isLoading } = useLiveQuery( + (q) => + q + .from({ members: collections.members }) + .leftJoin({ users: collections.users }, ({ members, users }) => + eq(members.userId, users.id), + ) + .select(({ members, users }) => ({ + memberId: members.id, + userId: members.userId, + name: users?.name ?? null, + email: users?.email ?? "", + image: users?.image ?? null, + role: members.role, + joinedAt: members.createdAt, + organizationId: members.organizationId, + })) + .orderBy(({ members }) => members.role, "asc") + .orderBy(({ members }) => members.createdAt, "asc"), [collections], ); - const { data: usersData, isLoading: isLoadingUsers } = useLiveQuery( - (q) => q.from({ users: collections.users }), - [collections], - ); - - const isLoading = isLoadingMembers || isLoadingUsers; - - // Join members with users and create member details - const memberDetails = - membersData && usersData - ? membersData.map((member) => { - const user = usersData.find((u) => u.id === member.userId); - return { - memberId: member.id, - userId: member.userId, - name: user?.name ?? null, - email: user?.email ?? "", - image: user?.image ?? null, - role: member.role, - joinedAt: - member.createdAt instanceof Date - ? member.createdAt.toISOString() - : member.createdAt, - organizationId: member.organizationId, - }; - }) - : []; - - // Sort by role (owner first) then by joinedAt - const members = memberDetails.slice().sort((a, b) => { - // Owners first - if (a.role === "owner" && b.role !== "owner") return -1; - if (a.role !== "owner" && b.role === "owner") return 1; - // Then by join date - return new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime(); - }); + const members = membersData ?? []; const currentUserId = session?.user?.id; const currentMember = members.find((m) => m.userId === currentUserId); const isOwner = currentMember?.role === "owner"; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString("en-US", { + const formatDate = (date: Date | string) => { + const d = date instanceof Date ? date : new Date(date); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", }); @@ -124,19 +106,17 @@ export function TeamSettings() { {members.map((member) => { - const initials = getInitials(member.name, member.email); const isCurrentUserRow = member.userId === currentUserId; return (
- - - - {initials || "?"} - - +
{member.name || "Unknown"} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberActions/MemberActions.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberActions/MemberActions.tsx index 0d4bbf960..526c67a0a 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberActions/MemberActions.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberActions/MemberActions.tsx @@ -25,7 +25,7 @@ export interface MemberDetails { email: string; image: string | null; role: string; - joinedAt: string; + joinedAt: Date; organizationId: string; } diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberRow/MemberRow.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberRow/MemberRow.tsx index 71cb99dc8..a6477eb60 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberRow/MemberRow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/TeamSettings/components/MemberRow/MemberRow.tsx @@ -1,6 +1,5 @@ import { authClient } from "@superset/auth/client"; -import { getInitials } from "@superset/shared/names"; -import { Avatar, AvatarFallback, AvatarImage } from "@superset/ui/avatar"; +import { Avatar } from "@superset/ui/atoms/Avatar"; import { Badge } from "@superset/ui/badge"; import { Button } from "@superset/ui/button"; import { @@ -48,17 +47,12 @@ export function MemberRow({ } }; - const initials = getInitials(member.name, member.email); - const isOwner = member.role === "owner"; return ( <>
- - - {initials || "?"} - +
diff --git a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx index 5e58414e8..dc8410297 100644 --- a/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TasksView/TasksView.tsx @@ -1,314 +1,44 @@ -import type { TaskPriority } from "@superset/db/enums"; -import type { SelectTask } from "@superset/db/schema"; -import { Badge } from "@superset/ui/badge"; -import { Button } from "@superset/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@superset/ui/card"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@superset/ui/dialog"; -import { Input } from "@superset/ui/input"; -import { Label } from "@superset/ui/label"; import { ScrollArea } from "@superset/ui/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@superset/ui/select"; -import { toast } from "@superset/ui/sonner"; -import { Textarea } from "@superset/ui/textarea"; -import { useLiveQuery } from "@tanstack/react-db"; import { useState } from "react"; -import { - HiCalendar, - HiCheckCircle, - HiLink, - HiPencil, - HiUser, -} from "react-icons/hi2"; -import { useCollections } from "renderer/contexts/CollectionsProvider"; +import { HiCheckCircle } from "react-icons/hi2"; +import { TasksTableView } from "./components/TasksTableView"; +import { type TabValue, TasksTopBar } from "./components/TasksTopBar"; +import { useTasksTable } from "./hooks/useTasksTable"; -interface TaskEditDialogProps { - task: SelectTask; - open: boolean; - onOpenChange: (open: boolean) => void; -} - -function TaskEditDialog({ task, open, onOpenChange }: TaskEditDialogProps) { - const collections = useCollections(); - const [title, setTitle] = useState(task.title); - const [description, setDescription] = useState(task.description || ""); - const [priority, setPriority] = useState(task.priority); - const [isSaving, setIsSaving] = useState(false); +export function TasksView() { + const [currentTab, setCurrentTab] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); - const handleSave = async () => { - setIsSaving(true); - try { - await collections.tasks.update(task.id, (draft: SelectTask) => { - draft.title = title; - draft.description = description || null; - draft.priority = priority as - | "urgent" - | "high" - | "medium" - | "low" - | "none"; - }); - toast.success("Task updated"); - onOpenChange(false); - } catch (error) { - console.error("[TaskEditDialog] Update failed:", error); - toast.error( - `Failed to update task: ${error instanceof Error ? error.message : String(error)}`, - ); - } finally { - setIsSaving(false); - } - }; + const { table, isLoading, slugColumnWidth } = useTasksTable({ + filterTab: currentTab, + searchQuery, + }); return ( - - - - - {task.externalKey && ( - - {task.externalKey} - - )} - Edit Task - - -
-
- - setTitle(e.target.value)} - /> -
-
- -