Mapped connections UI and API.
UnderstandImplemented disconnect endpoint and management page.
BuildMade sidebar connected section clickable with icons.
EditRan tests and typechecks; ensured no regressions.
VerifyFixed auth middleware to protect connections page.
EditI'll explore how connections are currently rendered and managed before making changes.
I've launched an exploration agent to map the connections UI and API. Let me wait for its findings before making changes.
While that runs, let me check the recent git context for the connections work.
Let me wait for the exploration agent's full report before diving into edits.
I'll let the agent finish its mapping. Waiting for its report.
I've launched a mapping agent and confirmed the data model: source_connection has (user_id, source, connected_account_id, status, metadata) with status initiated | active | error. Waiting on the agent's full UI/API map before editing.
I'll just wait for the agent's completion notification rather than polling. Let me end this turn and resume when the agent reports back.
The exploration agent is mapping the connections UI and API. I'll continue once it completes.
The agent gave me a complete map. Now I'll read the files I need to edit. First let me isolate in a worktree, then read the key files.
EnterWorktree isn't available from this background job context. Per the guidance, I'll continue in place. Let me read the key files I'll edit.
Now let me look at the app shell layout to understand where SourceDots mounts and how the sidebar/nav is structured.
I now have the full picture. The "CONNECTED" sidebar section (SourceDots) is what the user means — currently it's just static rows with a tiny status dot, no actions. I'll build: a disconnect endpoint, a real Connections management page, and make the sidebar section clickable. First, let me verify the Composio SDK's disconnect method name.
connectedAccounts.delete(nanoid) is the disconnect method. Let me check the connect route and the helpers it uses (enqueueLoad, isConnectable, errors), plus the connect test for patterns.
Disconnect = connectedAccounts.delete(id). My plan: add a DELETE handler to the connect route, build a full /connections management page, and make the sidebar CONNECTED section clickable/richer. Let me check icon names and an existing page's layout conventions.
Let me check the app layout and an existing page's structure to match conventions.
Let me check relativeTime, the connectable registry, and the existing connections test to keep tests green.
I have everything I need. Let me start implementing. First, the disconnect endpoint — I'll add a DELETE handler to the connect route.
Edits are guarded to a worktree, and EnterWorktree isn't usable from this context. I'll create a git worktree manually and make all edits there.
Worktree created. The main checkout has uncommitted changes — let me verify the files I'll edit match between HEAD (worktree base) and the working tree I read, so my edits apply cleanly.
Only lib/ui/source.ts differs (uncommitted user work I won't touch). Let me confirm the helpers I rely on exist at HEAD in the worktree.
All helpers exist at HEAD. Now I'll work entirely in the worktree. Let me apply the DELETE endpoint there.
I need to read the worktree copy first.
Now I'll apply the DELETE endpoint to the worktree copy.
CLAUDE.md was reformatted by a linter — not my concern. Now let me add the icons I need for the management UI. Reading the worktree's icons file first.
I'll add icons for the management UI: refresh, unlink, plus, check, and settings.
Formatter ran — fine. Now the main piece: the Connections management page. Let me create it in the worktree.
Let me add a clean x icon (the dismiss button currently misuses unlink).
Now update the dismiss button in the connections page to use x.
The formatter reformatted that region. Let me read it.
Now make the sidebar CONNECTED section clickable. Let me update SourceDots in the worktree.
Now make the "CONNECTED" header itself a clickable link to the management page.
Now let me verify the changes typecheck and tests pass. The worktree needs node_modules — let me check and set it up.
I'll symlink the main checkout's node_modules into the worktree so I can typecheck and run tests there.
Typecheck produced no errors. Let me confirm the exit code explicitly and run the relevant tests.
Typecheck clean. Now run the connection-related tests.
Connections tests pass. Let me check whether the connect route has tests, and run any.
No connect-route test exists. Let me mirror the connections test's mocking style to add a focused test for the new DELETE handler.
Now I'll add a focused test for the DELETE (disconnect) handler.
All 10 tests pass (the stderr lines are the expected captureError logs). Let me lint the changed files to catch any rule violations (no-semicolons, etc.).
next lint isn't configured in this project (prettier is the formatter, and the PostToolUse hook already formatted each file). Let me verify prettier is clean on my files.
Everything is clean: typecheck passes, 10/10 tests pass, prettier clean. Let me run the full test suite to confirm no regressions from the shared icon/sidebar changes.
Full suite green: 154 passed. Let me review the final diff in the worktree before committing.
That's a lot of unexpected modifications — a prettier --write . hook appears to have reformatted the whole worktree. Let me confirm these are formatting-noise (not real changes) before committing, then commit only my intended files.
Confirmed — it's pure prettier --write . reformatting noise (markdown tables, line wrapping) across files I never touched. I'll revert all that noise and keep only my 6 files.
Understood — those are hook-generated formatting changes in a disposable worktree, but I don't need to discard anything. I'll simply stage only my 6 files and commit; the unstaged formatting noise stays out of the commit and the PR.
Only my 6 files are staged. Let me sanity-check the staged diff to confirm it's just my work (plus acceptable prettier formatting on those same files).
Clean and correctly scoped — no formatting noise bled in. Let me commit.
Committed. Let me push the branch and open a PR.
Done. Sanity checks I ran before calling this complete: tsc --noEmit clean, full vitest suite 154 passed (30 files), prettier clean on all touched files, and the staged diff verified to contain only my 6 files (no formatter noise bled in).
What I built to make the Connected section actually useful:
Backend — DELETE /api/connect/[source] (app/api/connect/[source]/route.ts)
source_connection row so the scheduled poller drops it. Ingested history is intentionally kept. Revoking also unblocks "switch account," since Composio's link() refuses a second ACTIVE account.New /connections page (app/(app)/connections/page.tsx)
/api/connections every 5s so changes reflect within seconds.Sidebar CONNECTED section is now clickable (SourceDots.tsx, Sidebar.tsx)
/connections; richer live dots (green/amber/red/grey + tooltips), a hover settings affordance, and an "Add or manage sources" link.refresh/unlink/plus/check/settings/x icons.Pushed as branch feat/connections-management → PR #29 (https://github.com/venusbhatia/zrux/pull/29).
Two notes: I worked in a manually-created git worktree (EnterWorktree isn't usable from this background context) and committed only my 6 files — a prettier --write . formatter hook reformatted ~37 unrelated files in that disposable worktree, which I deliberately left out of the commit/PR. I didn't do a live browser pass (would need the dev server + an auth session); the page follows the existing app-shell/page patterns and typechecks.
One pre-existing wart, called out in the PR: the OAuth callback still redirects to /onboarding after consent, so a reconnect from /connections lands on onboarding showing indexing progress — functional, one extra click back. Threading a return destination through the callback is a possible follow-up.
result: Connections are now fully manageable — new /connections page with real per-source status/icons + connect/switch-account/reconnect/disconnect, clickable sidebar, and a DELETE disconnect endpoint; 154 tests pass, shipped as PR #29.
Let me pull GPT's review comments on PR #29.
Codex left one finding: a P2 — /connections isn't in the auth middleware matcher, so an unauthenticated direct hit reaches the (app) shell instead of redirecting to login. Let me verify against the actual middleware.
Valid finding. The matcher omits /connections, leaving it unprotected. Let me add it.
Now commit just the middleware fix and push.
API Error: 529 Overloaded. This is a server-side issue, usually temporary — try again in a moment. If it persists, check https://status.claude.com.
Typecheck passed and the staged diff is just the one line. Let me commit and push.
The safety classifier is temporarily unavailable. Let me wait briefly and retry the commit+push.
Pushed. Let me reply to the Codex inline comment so the verdict is closed out, then confirm.
Resolved. Codex left exactly one finding (a P2) and it's now fixed and acknowledged.
Codex P2 — /connections unprotected by auth middleware: The middleware.ts matcher listed /ask, /today, /relationships, /search, /onboarding but not /connections, so an unauthenticated direct hit would reach the (app) shell with the fallback user and the client poll would 401, leaving the page stuck loading.
Fix: added '/connections/:path*' to the matcher so it redirects to /login like every other app surface. Verified tsc --noEmit clean, staged diff is the single line, committed as c3fc7cf and pushed. Replied on the inline comment marking it resolved.
result: Resolved Codex's sole verdict (P2 auth-middleware gap) on PR #29 — added /connections/:path* to the middleware matcher, pushed c3fc7cf, and replied on the thread.
// GET /api/connections - the current tenant's source connection status, item// counts, and last sync time. Powers the sidebar live dots and the onboarding// indexing progress / unlock gate. Read-only; user_id scoped first (RLS second).
export interface ConnectionStatus { source: string status: string // 'initiated' | 'active' | 'error' updatedAt: string | null itemCount: number lastSyncedAt: string | null}
export async function GET(req: NextRequest): Promise<Response> { let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err }
try { const db = createServiceClient() const { data: rows, error } = await db .from('source_connection') .select('source, status, updated_at') .eq('user_id', userId) .order('updated_at', { ascending: false }) if (error) throw new Error(error.message)
const connections = rows ?? [] const syncRows = await db .from('sync_state') .select('source, last_successful_sync_at') .eq('user_id', userId) const lastSyncBySource = new Map( (syncRows.data ?? []).map((r) => [r.source, r.last_successful_sync_at]), )
// Item counts only for active connections (the only ones that can have data), // each a cheap head-count scoped by user_id + source. <=5 sources, so the // parallel fan-out is trivial. const active = connections.filter((c) => c.status === 'active') const counts = await Promise.all( active.map(async (c) => { const { count } = await db .from('context_item') .select('id', { count: 'exact', head: true }) .eq('user_id', userId) .eq('source', c.source) .eq('is_deleted', false) return [c.source, count ?? 0] as const }), ) const countBySource = new Map(counts)
const payload: ConnectionStatus[] = connections.map((c) => ({ source: c.source, status: c.status, updatedAt: c.updated_at, itemCount: countBySource.get(c.source) ?? 0, lastSyncedAt: lastSyncBySource.get(c.source) ?? null, }))
return Response.json({ connections: payload }) } catch (err) { captureError('connections', err, { userId }) return new Response('Failed to load connections', { status: 500 }) }}// POST /api/connect/[source] - start a Composio OAuth connection for the signed// in user. Returns the redirectUrl to send the user to. Persists an 'initiated'// source_connection row so the callback can finalize it. Fast (no ingestion).
async function reconcileActive(userId: string, source: SourceName): Promise<boolean> { const list = (await composio().connectedAccounts.list({ userIds: [userId], authConfigIds: [authConfigId(source)], statuses: ['ACTIVE'], })) as { items?: Array<{ id: string; status?: string }> } const active = list.items?.find((i) => (i.status ?? '').toUpperCase() === 'ACTIVE') if (!active) return false
const db = createServiceClient() const { error } = await db.from('source_connection').upsert( { user_id: userId, source, connected_account_id: active.id, status: 'active', updated_at: new Date().toISOString(), }, { onConflict: 'user_id,source' }, ) if (error) throw new Error(error.message) await enqueueLoad(userId, source) return true}
export async function POST( req: NextRequest, { params }: { params: { source: string } },): Promise<Response> { const source = params.source if (!isConnectable(source)) { return new Response(`Source not connectable: ${source}`, { status: 400 }) }
let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err }
try { const callbackUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/oauth/callback` const connRequest = (await composio().connectedAccounts.link(userId, authConfigId(source), { callbackUrl, })) as { id: string; redirectUrl?: string }
const db = createServiceClient() const { error } = await db.from('source_connection').upsert( { user_id: userId, source, connected_account_id: connRequest.id, status: 'initiated', updated_at: new Date().toISOString(), }, { onConflict: 'user_id,source' }, ) if (error) throw new Error(error.message)
return Response.json({ redirectUrl: connRequest.redirectUrl, connectedAccountId: connRequest.id, }) } catch (err) { if (err instanceof ComposioMultipleConnectedAccountsError) { try { if (await reconcileActive(userId, source)) { return Response.json({ alreadyConnected: true }) } } catch (reconcileErr) { captureError('connect', reconcileErr, { userId, source, stage: 'reconcile' }) return new Response('Failed to reconcile existing connection', { status: 502 }) } } captureError('connect', err, { userId, source }) return new Response('Failed to start connection', { status: 502 }) }}// GET /api/oauth/callback - Composio redirects here after the user grants// consent. We finalize any 'initiated' connections for this user (verify ACTIVE// via Composio), then ENQUEUE the first 90-day load on Trigger.dev.
export async function GET(req: NextRequest): Promise<Response> { let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err }
const db = createServiceClient() const { data: pending, error } = await db .from('source_connection') .select('source, connected_account_id') .eq('user_id', userId) .eq('status', 'initiated') if (error) { captureError('oauth/callback', new Error(error.message), { userId, stage: 'list-pending' }) return Response.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/onboarding?error=1`, 302) }
for (const conn of pending ?? []) { try { const account = (await composio().connectedAccounts.get(conn.connected_account_id)) as { status?: string } if ((account.status ?? '').toUpperCase() === 'ACTIVE') { await db .from('source_connection') .update({ status: 'active', updated_at: new Date().toISOString() }) .eq('user_id', userId) .eq('source', conn.source) await enqueueLoad(userId, conn.source) } } catch (err) { captureError('oauth/callback', err, { userId, source: conn.source, stage: 'finalize' }) } }
return Response.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/onboarding?connected=1`, 302)}// Composio client + helpers. Supplies OAuth + fetch inside the connectors. The// Connector contract is ours (CLAUDE.md), so a Nango swap is a one-file change.
import { Composio } from '@composio/core'import type { SourceName } from './types'
let client: Composio | null = null
export function composio(): Composio { if (!client) client = new Composio({ apiKey: requireEnv('COMPOSIO_API_KEY') }) return client}
// Auth config id per source (set after creating the auth config in Composio).const AUTH_CONFIG_ENV: Partial<Record<SourceName, string>> = { gmail: 'COMPOSIO_GMAIL_AUTH_CONFIG_ID', calendar: 'COMPOSIO_CALENDAR_AUTH_CONFIG_ID', linear: 'COMPOSIO_LINEAR_AUTH_CONFIG_ID', slack: 'COMPOSIO_SLACK_AUTH_CONFIG_ID', notion: 'COMPOSIO_NOTION_AUTH_CONFIG_ID', github: 'COMPOSIO_GITHUB_AUTH_CONFIG_ID',}
export function authConfigId(source: SourceName): string { const envName = AUTH_CONFIG_ENV[source] if (!envName) throw new Error(`No auth config mapping for source: ${source}`) return requireEnv(envName)}
export async function executeTool( slug: string, userId: string, args: Record<string, unknown>,): Promise<Record<string, unknown>> { // ... executes Composio tools with userId as tenant identifier}// Source + tone presentation maps. Keep the visual language (icon, label, tint)// consistent across the sidebar, Today cards, Search results, and the graph.
import type { IconName } from '@/components/icons'import type { TagTone, CardKind } from '@/lib/api/today-schema'
interface Tint { bg: string color: string}
const SOURCE_META: Record<string, { label: string; icon: IconName; tint: Tint }> = { gmail: { label: 'Gmail', icon: 'mail', tint: { bg: 'rgba(0,113,227,.10)', color: '#0071e3' } }, calendar: { label: 'Calendar', icon: 'calendar', tint: { bg: 'rgba(0,113,227,.10)', color: '#0071e3' }, }, slack: { label: 'Slack', icon: 'slack', tint: { bg: 'rgba(107,63,212,.10)', color: '#6b3fd4' } }, linear: { label: 'Linear', icon: 'linear', tint: { bg: 'rgba(26,127,55,.10)', color: '#1a7f37' }, }, notion: { label: 'Notion', icon: 'notion', tint: { bg: '#f0f0f2', color: '#6e6e73' } }, github: { label: 'GitHub', icon: 'github', tint: { bg: '#f0f0f2', color: '#6e6e73' } }, sentry: { label: 'Sentry', icon: 'alert', tint: { bg: 'rgba(227,89,0,.10)', color: '#c2540a' } }, drive: { label: 'Drive', icon: 'notion', tint: { bg: '#f0f0f2', color: '#6e6e73' } }, voice_memo: { label: 'Voice', icon: 'mic', tint: { bg: '#f0f0f2', color: '#6e6e73' } },}
const FALLBACK = { label: 'Source', icon: 'layers' as IconName, tint: { bg: '#f0f0f2', color: '#6e6e73' },}
export function sourceMeta(source: string) { return SOURCE_META[source] ?? FALLBACK}
export function sourceLabel(source: string): string { return sourceMeta(source).label}
export function sourceIcon(source: string): IconName { return sourceMeta(source).icon}
export function sourceTint(source: string): Tint { return sourceMeta(source).tint}
// Tag pill colors (matches tagStyle() in the mockup).const TONE: Record<TagTone, Tint> = { blue: { bg: 'rgba(0,113,227,.10)', color: '#0071e3' }, warn: { bg: 'rgba(227,89,0,.10)', color: '#c2540a' }, calm: { bg: 'rgba(0,0,0,.05)', color: '#6e6e73' }, green: { bg: 'rgba(26,127,55,.12)', color: '#1a7f37' }, purple: { bg: 'rgba(107,63,212,.10)', color: '#6b3fd4' },}
export function toneTint(tone: TagTone): Tint { return TONE[tone] ?? TONE.blue}
// Map a Today card kind to an icon. Source-shaped kinds reuse the source icon.const KIND_ICON: Record<CardKind, IconName> = { email: 'mail', calendar: 'calendar', slack: 'slack', linear: 'linear', notion: 'notion', github: 'github', sentry: 'alert', person: 'user', company: 'building', project: 'layers', generic: 'layers',}
export function kindIcon(kind: CardKind): IconName { return KIND_ICON[kind] ?? 'layers'}
// Entity type -> color/icon for the relationships graph + detail panel.export function entityColor(type: string): string { if (type === 'company') return '#6b3fd4' if (type === 'project') return '#1a7f37' return '#0071e3' // person / you / default}
export function entityIcon(type: string): IconName { if (type === 'company') return 'building' if (type === 'project') return 'layers' return 'user'}// A search result card: source-tinted icon tile, title + relative time, snippet// with highlighted match terms, source tag, author, and match percent.
export function ResultCard({ result }: { result: SearchResult }) { const tint = sourceTint(result.source) const body = ( <article className="flex gap-[13px] rounded-[14px] border border-hairline bg-white px-4 py-[15px] shadow-flat transition-colors hover:border-hairline-strong"> <div className="grid h-[34px] w-[34px] flex-none place-items-center rounded-[9px]" style={{ background: tint.bg, color: tint.color }} > <Icon name={sourceIcon(result.source)} size={16} /> </div> {/* ... rest of card ... */} <span className="inline-flex items-center rounded-pill px-[9px] py-[3px] text-[11px] font-semibold" style={{ background: tint.bg, color: tint.color }} > {sourceLabel(result.source)} </span> </article> ) // ...}-- Tracks each tenant's connected source accounts (the Composio connected-account-- handle). One row per (user, source). The scheduled poller iterates the active-- rows; the OAuth callback flips status to 'active' and kicks the first load.
create table source_connection ( user_id uuid not null, source text not null, connected_account_id text not null, status text not null default 'initiated', -- 'initiated' | 'active' | 'error' metadata jsonb default '{}', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), primary key (user_id, source));
create index source_connection_active_idx on source_connection (status) where status = 'active';
alter table source_connection enable row level security;create policy source_connection_tenant_isolation on source_connection using (user_id = auth.uid()) with check (user_id = auth.uid());// Example from SourceDots.tsxuseEffect(() => { let alive = true async function poll() { try { const res = await fetch('/api/connections') if (!res.ok) return const data = (await res.json()) as { connections: ConnectionStatus[] } if (alive) setConnections(data.connections) } catch { // keep last state } } void poll() const id = setInterval(poll, 8000) // or 3000 for onboarding return () => { alive = false clearInterval(id) }}, []){ connections: [ { source: string status: 'initiated' | 'active' | 'error' updatedAt: string | null itemCount: number lastSyncedAt: string | null } ]}export async function DELETE( req: NextRequest, { params }: { params: { source: string } },): Promise<Response> { const source = params.source if (!isConnectable(source)) { return new Response(`Source not connectable: ${source}`, { status: 400 }) } let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err } try { const db = createServiceClient() const { data: row, error: readErr } = await db .from('source_connection') .select('connected_account_id') .eq('user_id', userId).eq('source', source).maybeSingle() if (readErr) throw new Error(readErr.message) if (row?.connected_account_id) { try { await composio().connectedAccounts.delete(row.connected_account_id) } catch (revokeErr) { captureError('connect', revokeErr, { userId, source, stage: 'revoke' }) } } const { error: delErr } = await db .from('source_connection').delete() .eq('user_id', userId).eq('source', source) if (delErr) throw new Error(delErr.message) return Response.json({ disconnected: true }) } catch (err) { captureError('connect', err, { userId, source }) return new Response('Failed to disconnect', { status: 502 }) }}<Link href="/connections" title="Manage connections" className="mt-6 flex items-center justify-between px-[10px] pb-2 text-[11px] font-semibold tracking-[.05em] text-hint transition-colors hover:text-ink"> <span>CONNECTED</span> <Icon name="settings" size={13} /></Link><SourceDots />