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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
481 changes: 481 additions & 0 deletions stackunderflow-ui/src/components/dashboard/DevicesTab.tsx

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions stackunderflow-ui/src/config/dashboardTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
IconWallet,
IconActivityHeartbeat,
IconBinaryTree2,
IconArrowsLeftRight,
} from '@tabler/icons-react'

export type Tab = {
Expand Down Expand Up @@ -79,6 +80,13 @@ export const TABS: readonly Tab[] = [
// copyable prune-command PREVIEWS (the tool never runs them). Calls the
// dedicated /api/worktrees route. Beta while the verdict heuristics settle.
{ id: 'worktrees', label: 'Worktrees', icon: IconBinaryTree2, isBeta: true },
// Multi-device sync overlay (#100 Phase 2) — the cross-device analytics view.
// Gated behind sync being configured (via /api/sync/status): with sync off the
// tab shows a clean "not set up" empty state and never touches the per-device
// Overview/Cost render paths. A This device ↔ All devices toggle flips to the
// merged `local UNION ALL <mart>_remote` roll-up (/api/sync/overview). Beta
// while the union overlay settles.
{ id: 'devices', label: 'Devices', icon: IconArrowsLeftRight, isBeta: true },
{ id: 'commands', label: 'Commands', icon: IconTerminal2 },
{ id: 'messages', label: 'Messages', icon: IconMessageCircle },
{ id: 'search', label: 'Search', icon: IconSearch },
Expand Down
2 changes: 2 additions & 0 deletions stackunderflow-ui/src/pages/ProjectDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const YieldTab = lazy(() => import('../components/dashboard/YieldTab'))
const ForksTab = lazy(() => import('../components/dashboard/ForksTab'))
const CodingHealthTab = lazy(() => import('../components/dashboard/CodingHealthTab'))
const WorktreesTab = lazy(() => import('../components/dashboard/WorktreesTab'))
const DevicesTab = lazy(() => import('../components/dashboard/DevicesTab'))
import {
useBetaFeatures,
BETA_ENABLED_KEY,
Expand Down Expand Up @@ -392,6 +393,7 @@ export default function ProjectDashboard() {
{activeTab === 'forks' && <ForksTab projectName={name!} />}
{activeTab === 'health' && <CodingHealthTab projectName={name!} />}
{activeTab === 'worktrees' && <WorktreesTab projectName={name!} />}
{activeTab === 'devices' && <DevicesTab projectName={name!} />}
{activeTab === 'commands' && <CommandsTab data={dashboardData} />}
{activeTab === 'messages' && <MessagesTab data={dashboardData} projectName={name!} />}
{activeTab === 'search' && <SearchTab projectName={name!} initialQuery={initialSearchQuery} />}
Expand Down
52 changes: 52 additions & 0 deletions stackunderflow-ui/src/services/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// ---------------------------------------------------------------------------
// Multi-device sync client — `GET /api/sync/status` + `GET /api/sync/overview`
// (#100 Phase 2, the union read overlay).
//
// Its own module (not `api.ts`): same isolation rationale as `worktrees.ts` /
// `patterns.ts` — the sync surface is an additive, self-contained unit, so a
// disjoint client keeps it file-separate from parallel work on the main API
// client. The fetch helper mirrors `api.ts::fetchJson` exactly.
//
// Both endpoints are read-only. `getSyncStatus` is a pure local read (safe with
// sync off). `getSyncOverview` only runs the cross-device union on the opt-in
// `?scope=all-devices` path — the default `this-device` returns a tiny stub.
// ---------------------------------------------------------------------------

import type { SyncOverview, SyncStatus } from '../types/api'

const BASE = '/api'

async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`)
}
return res.json()
}

/** Scope selector for {@link getSyncOverview}. `this-device` (the default) is
* the cheap stub path; `all-devices` triggers the union roll-up. */
export type SyncScope = 'this-device' | 'all-devices'

/**
* Local sync config + known peers + whether any cross-device data has been
* pulled. Pure local read — never hits the network or a bucket, and works
* whether sync is configured or not (returns `enabled: false` when it isn't).
*/
export async function getSyncStatus(): Promise<SyncStatus> {
return fetchJson(`${BASE}/sync/status`)
}

/**
* The cross-device overview. Defaults to `this-device`, which returns a tiny
* not-merged stub and runs no union (so a store with sync off behaves as if the
* feature were absent). Only `scope='all-devices'` (with sync enabled) computes
* the `local UNION ALL <mart>_remote` roll-up; its cost fields arrive
* pre-converted to the active currency (same contract as /api/forks,
* /api/worktrees). The response is discriminated on `merged`.
*/
export async function getSyncOverview(scope: SyncScope = 'this-device'): Promise<SyncOverview> {
const params = new URLSearchParams({ scope })
return fetchJson(`${BASE}/sync/overview?${params.toString()}`)
}
125 changes: 125 additions & 0 deletions stackunderflow-ui/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1599,3 +1599,128 @@ export interface WorktreesResponse {
export interface WorktreeAttributeResponse {
updated: number
}

// ── Multi-device sync (#100 Phase 2 — the union read overlay) ───────────────
// Shapes mirror `stackunderflow/routes/sync.py` exactly. Both endpoints are
// read-only and safe on a core install: `/api/sync/status` is a pure local
// read, and `/api/sync/overview` only runs the cross-device union on the opt-in
// `?scope=all-devices` path (the default returns a tiny not-merged stub).

/** One peer device from `sync_remote_devices` (empty until the first pull).
* Nullable columns are typed defensively — `alias`/`key_fingerprint` are set
* to NULL on first sight and populated later; timestamps come from upserts. */
export interface SyncPeer {
remote_device_uuid: string
alias: string | null
key_fingerprint: string | null
first_seen: string | null
last_seen: string | null
last_generation: number
}

/** GET /api/sync/status — local sync config + known peers + availability.
* Works whether sync is on or off; never touches the network. */
export interface SyncStatus {
enabled: boolean
device_uuid: string | null
fingerprint: string | null
bucket_url: string | null
endpoint_url: string | null
shard_count: number
pending: string[]
pending_count: number
last_push_ts: string | null
peers: SyncPeer[]
peer_count: number
/** Total rows landed across every `<mart>_remote` table (0 ⇒ nothing pulled). */
remote_rows: number
/** True only when sync is enabled AND cross-device rows exist — the FE gates
* the all-devices view on this flag. */
all_devices_available: boolean
scanned_at: string
}

/** Merged totals across every contributing device (cost pre-converted). */
export interface SyncTotals {
cost_usd: number
input_tokens: number
output_tokens: number
cache_read: number
cache_create: number
message_count: number
/** Deduped unique sessions across devices (a session never spans machines). */
session_count: number
}

/** One point on the merged per-day cost/token/message trend. */
export interface SyncByDay {
day: string
cost_usd: number
input_tokens: number
output_tokens: number
message_count: number
}

/** Merged per-project totals at the stable `(provider, slug)` grain. */
export interface SyncByProject {
provider: string
slug: string
display_name: string | null
first_ts: string | null
last_ts: string | null
total_messages: number
total_sessions: number
total_input_tokens: number
total_output_tokens: number
total_cache_read: number
total_cache_create: number
total_cost_usd: number
}

/** Merged per-provider-day roll-up (additive measures are exact). */
export interface SyncByProviderDay {
day: string
provider: string
cost_usd: number
message_count: number
session_count: number
project_count: number
}

/** Per-contributing-device breakdown (this device + each pulled peer). */
export interface SyncDevice {
device_uuid: string
alias: string | null
is_local: boolean
projects: number
cost_usd: number
}

/** GET /api/sync/overview default / sync-off — a minimal not-merged stub; no
* union query runs. Discriminated from the merged payload by `merged: false`. */
export interface SyncOverviewStub {
scope: 'this-device'
merged: false
sync_enabled: boolean
hint: string
}

/** GET /api/sync/overview?scope=all-devices (sync enabled) — the
* `local UNION ALL <mart>_remote` roll-up. Cost figures are pre-converted into
* the active currency, matching every other cost endpoint's contract. */
export interface SyncOverviewMerged {
scope: 'all-devices'
merged: true
sync_enabled: true
totals: SyncTotals
by_day: SyncByDay[]
by_project: SyncByProject[]
by_provider_day: SyncByProviderDay[]
devices: SyncDevice[]
merge_warnings: number
currency: CurrencyInfo
generated_at: string
}

/** GET /api/sync/overview — discriminated on `merged`. */
export type SyncOverview = SyncOverviewStub | SyncOverviewMerged
Loading
Loading