Call Debugger Dashboard Implementation Plan¶
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Redesign the call debugger dashboard with multi-sidebar navigation, org/location hierarchy (prod), and agent grouping (dev).
Architecture: Multi-sidebar drill-down UI with separate backend paths for prod (Hyperdrive/MySQL) and dev (S3). Sidebars collapse when viewing a call, breadcrumb navigation to return.
Tech Stack: React 19, Hono, Hyperdrive, fuse.js (fuzzy search), Tailwind CSS
Linear Ticket: ARE-1557
Design Doc: docs/plans/2026-02-04-call-debugger-dashboard-design.md
Phase 1: Backend - Prod Routes¶
Task 1: Add Day Index Query¶
Files:
- Create: src/worker/modules/debug/queries/dayIndex.ts
Step 1: Create the day index query file
import type { Connection } from "mysql2/promise"
export type OrgLocationIndex = {
org_id: string
org_name: string
location_id: string
location_name: string
call_count: number
}
export async function getDayIndex(
conn: Connection,
date: string
): Promise<OrgLocationIndex[]> {
const [rows] = await conn.query<OrgLocationIndex[]>(
`SELECT
o.uid AS org_id,
o.name AS org_name,
ol.uid AS location_id,
ol.name AS location_name,
COUNT(rc.retell_call_id) AS call_count
FROM retell_calls rc
JOIN calls c ON rc.arini_call_id = c.uid
JOIN org_locations ol ON c.to_id = ol.uid
JOIN orgs o ON ol.org_id = o.uid
WHERE DATE(rc.created_at) = ?
GROUP BY o.uid, o.name, ol.uid, ol.name
ORDER BY call_count DESC`,
[date]
)
return rows
}
Step 2: Verify file compiles
Run: devenv shell -- pnpm build
Expected: Build succeeds with no TypeScript errors
Step 3: Commit
git add src/worker/modules/debug/queries/dayIndex.ts
git commit -m "feat(debug): add day index query for org/location hierarchy"
Task 2: Add Location Calls Query¶
Files:
- Create: src/worker/modules/debug/queries/locationCalls.ts
Step 1: Create the location calls query file
import type { Connection, RowDataPacket } from "mysql2/promise"
export type LocationCall = {
call_id: string
arini_call_id: string
created_at: string
direction: "inbound" | "outbound" | null
}
type LocationCallRow = RowDataPacket & LocationCall
export async function getLocationCalls(
conn: Connection,
locationId: string,
date: string,
limit: number,
offset: number
): Promise<{ calls: LocationCall[]; total: number }> {
const [[countRow]] = await conn.query<RowDataPacket[]>(
`SELECT COUNT(*) as total
FROM retell_calls rc
JOIN calls c ON rc.arini_call_id = c.uid
WHERE c.to_id = ? AND DATE(rc.created_at) = ?`,
[locationId, date]
)
const [rows] = await conn.query<LocationCallRow[]>(
`SELECT
rc.retell_call_id AS call_id,
rc.arini_call_id,
rc.created_at,
JSON_UNQUOTE(JSON_EXTRACT(rc.retell_call_details, '$.call_type')) AS direction
FROM retell_calls rc
JOIN calls c ON rc.arini_call_id = c.uid
WHERE c.to_id = ? AND DATE(rc.created_at) = ?
ORDER BY rc.created_at DESC
LIMIT ? OFFSET ?`,
[locationId, date, limit, offset]
)
return {
calls: rows,
total: (countRow as { total: number }).total,
}
}
Step 2: Verify file compiles
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/worker/modules/debug/queries/locationCalls.ts
git commit -m "feat(debug): add paginated location calls query"
Task 3: Add Day Index API Route¶
Files:
- Modify: src/worker/modules/debug/router.ts
Step 1: Read current router structure
Read src/worker/modules/debug/router.ts to understand existing patterns.
Step 2: Add the /db/index route
Add import at top:
Add route handler (place near other db routes):
// GET /api/debug/db/index?date=YYYY-MM-DD
app.get("/db/index", async (c) => {
const date = c.req.query("date")
if (!date) {
return c.json({ error: "date query parameter required" }, 400)
}
const result = await withConnection(c.env, c.executionCtx, async (conn) => {
const rows = await getDayIndex(conn, date)
// Transform flat rows into nested org -> locations structure
const orgsMap = new Map<string, {
org_id: string
org_name: string
call_count: number
locations: { location_id: string; location_name: string; call_count: number }[]
}>()
for (const row of rows) {
let org = orgsMap.get(row.org_id)
if (!org) {
org = {
org_id: row.org_id,
org_name: row.org_name,
call_count: 0,
locations: [],
}
orgsMap.set(row.org_id, org)
}
org.locations.push({
location_id: row.location_id,
location_name: row.location_name,
call_count: row.call_count,
})
org.call_count += row.call_count
}
// Sort orgs by call_count descending
const orgs = Array.from(orgsMap.values()).sort((a, b) => b.call_count - a.call_count)
return { orgs }
})
return c.json(result)
})
Step 3: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 4: Test manually
Run: devenv shell -- pnpm dev
Test: curl "http://localhost:5173/api/debug/db/index?date=2026-02-04" -u arini:ariniisgod
Expected: JSON response with orgs array (may be empty if no calls for that date)
Step 5: Commit
git add src/worker/modules/debug/router.ts
git commit -m "feat(debug): add /db/index route for day hierarchy"
Task 4: Add Location Calls API Route¶
Files:
- Modify: src/worker/modules/debug/router.ts
Step 1: Add import
Step 2: Add the /db/locations/:olid/calls route
// GET /api/debug/db/locations/:olid/calls?date=YYYY-MM-DD&offset=0&limit=50
app.get("/db/locations/:olid/calls", async (c) => {
const olid = c.req.param("olid")
const date = c.req.query("date")
const offset = parseInt(c.req.query("offset") || "0", 10)
const limit = parseInt(c.req.query("limit") || "50", 10)
if (!date) {
return c.json({ error: "date query parameter required" }, 400)
}
const result = await withConnection(c.env, c.executionCtx, async (conn) => {
const { calls, total } = await getLocationCalls(conn, olid, date, limit, offset)
return {
calls,
total,
hasMore: offset + calls.length < total,
}
})
return c.json(result)
})
Step 3: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 4: Commit
git add src/worker/modules/debug/router.ts
git commit -m "feat(debug): add /db/locations/:olid/calls route"
Phase 2: Backend - Dev Routes¶
Task 5: Add S3 Index Route¶
Files:
- Modify: src/worker/modules/debug/router.ts
Step 1: Read existing S3 call listing code
Read the existing /api/calls route in the router to understand the S3 listing pattern.
Step 2: Add /s3/index route
// GET /api/debug/s3/index?date=YYYY-MM-DD
app.get("/s3/index", async (c) => {
const date = c.req.query("date")
if (!date) {
return c.json({ error: "date query parameter required" }, 400)
}
// Reuse existing S3 listing logic
const bucketName = "arini-call-artifacts-dev"
const prefix = `${date}/`
const s3 = new S3Client({
region: c.env.AWS_REGION,
credentials: {
accessKeyId: c.env.AWS_ACCESS_KEY_ID,
secretAccessKey: c.env.AWS_SECRET_ACCESS_KEY,
},
})
const command = new ListObjectsV2Command({
Bucket: bucketName,
Prefix: prefix,
Delimiter: "/",
})
const response = await s3.send(command)
const callDirs = response.CommonPrefixes || []
// Extract call IDs and group by agent_id (from call metadata if available)
// For now, return flat list - agent grouping can be done client-side
const calls = callDirs.map((dir) => {
const callId = dir.Prefix?.replace(prefix, "").replace("/", "") || ""
return {
call_id: callId,
date,
}
})
return c.json({ calls })
})
Step 3: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 4: Commit
git add src/worker/modules/debug/router.ts
git commit -m "feat(debug): add /s3/index route for dev environment"
Phase 3: Shared Types¶
Task 6: Add Response Types¶
Files:
- Modify: src/ui/modules/debug/types/debugger.ts
Step 1: Add new types at end of file
// Day Index Types (Prod)
export type DayIndexOrg = {
org_id: string
org_name: string
call_count: number
locations: DayIndexLocation[]
}
export type DayIndexLocation = {
location_id: string
location_name: string
call_count: number
}
export type DayIndexResponse = {
orgs: DayIndexOrg[]
}
// Location Calls Types (Prod)
export type LocationCallsResponse = {
calls: {
call_id: string
arini_call_id: string
created_at: string
direction: "inbound" | "outbound" | null
}[]
total: number
hasMore: boolean
}
// Dev Index Types
export type DevDayIndexResponse = {
calls: {
call_id: string
date: string
}[]
}
// Agent metadata mapping (hardcoded for dev)
export type AgentMetadata = {
agent_id: string
agent_name: string
}
export const DEV_AGENT_METADATA: AgentMetadata[] = [
// TODO: Add agent mappings when available
// { agent_id: "agent_abc123", agent_name: "Main Scheduling Agent" },
]
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/types/debugger.ts
git commit -m "feat(debug): add types for day index and location calls"
Phase 4: Frontend - Data Hooks¶
Task 7: Add useDayIndex Hook¶
Files:
- Create: src/ui/modules/debug/hooks/useDayIndex.ts
Step 1: Create the hook
import { useCallback, useEffect, useState } from "react"
import { fetchWithAuth } from "@/lib/api"
import type { DayIndexResponse } from "../types/debugger"
export function useDayIndex(date: string | null) {
const [data, setData] = useState<DayIndexResponse | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const fetchIndex = useCallback(async (signal: AbortSignal) => {
if (!date) return
setIsLoading(true)
setError(null)
try {
const response = await fetchWithAuth(
`/api/debug/db/index?date=${date}`,
{ signal }
)
if (!response.ok) {
throw new Error(`Failed to fetch day index: ${response.status}`)
}
const json = await response.json()
if (!signal.aborted) {
setData(json)
}
} catch (e) {
if (!signal.aborted) {
setError(e instanceof Error ? e : new Error(String(e)))
}
} finally {
if (!signal.aborted) {
setIsLoading(false)
}
}
}, [date])
useEffect(() => {
const controller = new AbortController()
fetchIndex(controller.signal)
return () => controller.abort()
}, [fetchIndex])
const refetch = useCallback(() => {
const controller = new AbortController()
fetchIndex(controller.signal)
}, [fetchIndex])
return { data, isLoading, error, refetch }
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
Task 8: Add useLocationCalls Hook¶
Files:
- Create: src/ui/modules/debug/hooks/useLocationCalls.ts
Step 1: Create the hook
import { useCallback, useEffect, useState } from "react"
import { fetchWithAuth } from "@/lib/api"
import type { LocationCallsResponse } from "../types/debugger"
export function useLocationCalls(
locationId: string | null,
date: string | null
) {
const [data, setData] = useState<LocationCallsResponse | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [offset, setOffset] = useState(0)
const limit = 50
const fetchCalls = useCallback(
async (signal: AbortSignal, currentOffset: number, append: boolean) => {
if (!locationId || !date) return
setIsLoading(true)
setError(null)
try {
const response = await fetchWithAuth(
`/api/debug/db/locations/${locationId}/calls?date=${date}&offset=${currentOffset}&limit=${limit}`,
{ signal }
)
if (!response.ok) {
throw new Error(`Failed to fetch calls: ${response.status}`)
}
const json: LocationCallsResponse = await response.json()
if (!signal.aborted) {
setData((prev) =>
append && prev
? { ...json, calls: [...prev.calls, ...json.calls] }
: json
)
}
} catch (e) {
if (!signal.aborted) {
setError(e instanceof Error ? e : new Error(String(e)))
}
} finally {
if (!signal.aborted) {
setIsLoading(false)
}
}
},
[locationId, date]
)
// Reset and fetch when location/date changes
useEffect(() => {
setOffset(0)
setData(null)
const controller = new AbortController()
fetchCalls(controller.signal, 0, false)
return () => controller.abort()
}, [fetchCalls])
const loadMore = useCallback(() => {
const newOffset = offset + limit
setOffset(newOffset)
const controller = new AbortController()
fetchCalls(controller.signal, newOffset, true)
}, [offset, fetchCalls])
return {
calls: data?.calls || [],
total: data?.total || 0,
hasMore: data?.hasMore || false,
isLoading,
error,
loadMore,
}
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/hooks/useLocationCalls.ts
git commit -m "feat(debug): add useLocationCalls hook with pagination"
Task 9: Add useDevDayIndex Hook¶
Files:
- Create: src/ui/modules/debug/hooks/useDevDayIndex.ts
Step 1: Create the hook
import { useCallback, useEffect, useState } from "react"
import { fetchWithAuth } from "@/lib/api"
import type { DevDayIndexResponse } from "../types/debugger"
export function useDevDayIndex(date: string | null) {
const [data, setData] = useState<DevDayIndexResponse | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const fetchIndex = useCallback(async (signal: AbortSignal) => {
if (!date) return
setIsLoading(true)
setError(null)
try {
const response = await fetchWithAuth(
`/api/debug/s3/index?date=${date}`,
{ signal }
)
if (!response.ok) {
throw new Error(`Failed to fetch dev index: ${response.status}`)
}
const json = await response.json()
if (!signal.aborted) {
setData(json)
}
} catch (e) {
if (!signal.aborted) {
setError(e instanceof Error ? e : new Error(String(e)))
}
} finally {
if (!signal.aborted) {
setIsLoading(false)
}
}
}, [date])
useEffect(() => {
const controller = new AbortController()
fetchIndex(controller.signal)
return () => controller.abort()
}, [fetchIndex])
const refetch = useCallback(() => {
const controller = new AbortController()
fetchIndex(controller.signal)
}, [fetchIndex])
return { data, isLoading, error, refetch }
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/hooks/useDevDayIndex.ts
git commit -m "feat(debug): add useDevDayIndex hook"
Phase 5: Frontend - UI Components¶
Task 10: Add fuse.js Dependency¶
Files:
- Modify: package.json
Step 1: Install fuse.js
Run: devenv shell -- pnpm add fuse.js
Step 2: Verify installation
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
Task 11: Create DayNavigator Component¶
Files:
- Create: src/ui/modules/debug/components/DayNavigator.tsx
Step 1: Create the component
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
type DayNavigatorProps = {
date: string // YYYY-MM-DD
onDateChange: (date: string) => void
}
function formatDateDisplay(dateStr: string): string {
const date = new Date(dateStr + "T00:00:00")
return date.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})
}
function addDays(dateStr: string, days: number): string {
const date = new Date(dateStr + "T00:00:00")
date.setDate(date.getDate() + days)
return date.toISOString().split("T")[0]
}
export function DayNavigator({ date, onDateChange }: DayNavigatorProps) {
const today = new Date().toISOString().split("T")[0]
const isToday = date === today
return (
<div className="flex items-center justify-center gap-2 py-3 px-4 border-b border-border">
<Button
variant="ghost"
size="icon"
onClick={() => onDateChange(addDays(date, -1))}
className="h-8 w-8"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium min-w-[240px] text-center">
{formatDateDisplay(date)}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => onDateChange(addDays(date, 1))}
disabled={isToday}
className="h-8 w-8"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/components/DayNavigator.tsx
git commit -m "feat(debug): add DayNavigator component"
Task 12: Create OrgSidebar Component¶
Files:
- Create: src/ui/modules/debug/components/OrgSidebar.tsx
Step 1: Create the component
import { useMemo, useState } from "react"
import Fuse from "fuse.js"
import { Building2, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type { DayIndexOrg } from "../types/debugger"
type OrgSidebarProps = {
orgs: DayIndexOrg[]
selectedOrgId: string | null
onSelectOrg: (orgId: string) => void
isLoading?: boolean
}
export function OrgSidebar({
orgs,
selectedOrgId,
onSelectOrg,
isLoading,
}: OrgSidebarProps) {
const [search, setSearch] = useState("")
const fuse = useMemo(
() => new Fuse(orgs, { keys: ["org_name"], threshold: 0.3 }),
[orgs]
)
const filteredOrgs = useMemo(() => {
if (!search.trim()) return orgs
return fuse.search(search).map((result) => result.item)
}, [orgs, fuse, search])
if (isLoading) {
return (
<div className="w-48 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="h-8 bg-surface-2 rounded animate-pulse" />
</div>
<div className="flex-1 p-2 space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-surface-2 rounded animate-pulse" />
))}
</div>
</div>
)
}
return (
<div className="w-48 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search orgs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filteredOrgs.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
{search ? "No matching orgs" : "No orgs with calls"}
</div>
) : (
filteredOrgs.map((org) => (
<button
key={org.org_id}
type="button"
onClick={() => onSelectOrg(org.org_id)}
className={cn(
"w-full p-2 text-left hover:bg-surface-2 transition-colors",
"border-b border-border/50",
selectedOrgId === org.org_id && "bg-surface-2 border-l-2 border-l-blue-500"
)}
>
<div className="flex items-center gap-2">
<Building2 className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-medium truncate">{org.org_name}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 pl-5">
{org.call_count.toLocaleString()} calls
</div>
</button>
))
)}
</div>
</div>
)
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/components/OrgSidebar.tsx
git commit -m "feat(debug): add OrgSidebar component with fuzzy search"
Task 13: Create LocationSidebar Component¶
Files:
- Create: src/ui/modules/debug/components/LocationSidebar.tsx
Step 1: Create the component
import { useMemo, useState } from "react"
import Fuse from "fuse.js"
import { MapPin, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type { DayIndexLocation } from "../types/debugger"
type LocationSidebarProps = {
locations: DayIndexLocation[]
selectedLocationId: string | null
onSelectLocation: (locationId: string) => void
isLoading?: boolean
}
export function LocationSidebar({
locations,
selectedLocationId,
onSelectLocation,
isLoading,
}: LocationSidebarProps) {
const [search, setSearch] = useState("")
const fuse = useMemo(
() => new Fuse(locations, { keys: ["location_name"], threshold: 0.3 }),
[locations]
)
const filteredLocations = useMemo(() => {
if (!search.trim()) return locations
return fuse.search(search).map((result) => result.item)
}, [locations, fuse, search])
if (isLoading) {
return (
<div className="w-48 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="h-8 bg-surface-2 rounded animate-pulse" />
</div>
<div className="flex-1 p-2 space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-surface-2 rounded animate-pulse" />
))}
</div>
</div>
)
}
return (
<div className="w-48 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search locations..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filteredLocations.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
{search ? "No matching locations" : "Select an org"}
</div>
) : (
filteredLocations.map((loc) => (
<button
key={loc.location_id}
type="button"
onClick={() => onSelectLocation(loc.location_id)}
className={cn(
"w-full p-2 text-left hover:bg-surface-2 transition-colors",
"border-b border-border/50",
selectedLocationId === loc.location_id && "bg-surface-2 border-l-2 border-l-blue-500"
)}
>
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-medium truncate">{loc.location_name}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 pl-5">
{loc.call_count.toLocaleString()} calls
</div>
</button>
))
)}
</div>
</div>
)
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/components/LocationSidebar.tsx
git commit -m "feat(debug): add LocationSidebar component"
Task 14: Create CallsSidebar Component¶
Files:
- Create: src/ui/modules/debug/components/CallsSidebar.tsx
Step 1: Create the component
import { useMemo, useState } from "react"
import Fuse from "fuse.js"
import { Phone, PhoneIncoming, PhoneOutgoing, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
type Call = {
call_id: string
arini_call_id?: string
created_at?: string
direction?: "inbound" | "outbound" | null
}
type CallsSidebarProps = {
calls: Call[]
selectedCallId: string | null
onSelectCall: (callId: string) => void
hasMore?: boolean
onLoadMore?: () => void
isLoading?: boolean
}
function formatTime(dateStr?: string): string {
if (!dateStr) return ""
const date = new Date(dateStr)
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})
}
function DirectionIcon({ direction }: { direction?: "inbound" | "outbound" | null }) {
if (direction === "inbound") {
return <PhoneIncoming className="h-3 w-3 text-green-500" />
}
if (direction === "outbound") {
return <PhoneOutgoing className="h-3 w-3 text-blue-500" />
}
return <Phone className="h-3 w-3 text-muted-foreground" />
}
export function CallsSidebar({
calls,
selectedCallId,
onSelectCall,
hasMore,
onLoadMore,
isLoading,
}: CallsSidebarProps) {
const [search, setSearch] = useState("")
const fuse = useMemo(
() => new Fuse(calls, { keys: ["call_id", "arini_call_id"], threshold: 0.3 }),
[calls]
)
const filteredCalls = useMemo(() => {
if (!search.trim()) return calls
return fuse.search(search).map((result) => result.item)
}, [calls, fuse, search])
if (isLoading && calls.length === 0) {
return (
<div className="w-56 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="h-8 bg-surface-2 rounded animate-pulse" />
</div>
<div className="flex-1 p-2 space-y-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-10 bg-surface-2 rounded animate-pulse" />
))}
</div>
</div>
)
}
return (
<div className="w-56 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search calls..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filteredCalls.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
{search ? "No matching calls" : "Select a location"}
</div>
) : (
<>
{filteredCalls.map((call) => (
<button
key={call.call_id}
type="button"
onClick={() => onSelectCall(call.call_id)}
className={cn(
"w-full p-2 text-left hover:bg-surface-2 transition-colors",
"border-b border-border/50",
selectedCallId === call.call_id && "bg-surface-2 border-l-2 border-l-blue-500"
)}
>
<div className="flex items-center gap-2">
<DirectionIcon direction={call.direction} />
<span className="text-xs font-mono truncate">
{call.call_id.slice(0, 16)}...
</span>
</div>
{call.created_at && (
<div className="text-[10px] text-muted-foreground mt-0.5 pl-5">
{formatTime(call.created_at)}
</div>
)}
</button>
))}
{hasMore && !search && (
<div className="p-2">
<Button
variant="ghost"
size="sm"
onClick={onLoadMore}
disabled={isLoading}
className="w-full text-xs"
>
{isLoading ? "Loading..." : "Load more"}
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/components/CallsSidebar.tsx
git commit -m "feat(debug): add CallsSidebar component with pagination"
Task 15: Create AgentSidebar Component (Dev)¶
Files:
- Create: src/ui/modules/debug/components/AgentSidebar.tsx
Step 1: Create the component
import { useMemo, useState } from "react"
import Fuse from "fuse.js"
import { Bot, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { DEV_AGENT_METADATA } from "../types/debugger"
type Agent = {
agent_id: string
call_count: number
}
type AgentSidebarProps = {
agents: Agent[]
selectedAgentId: string | null
onSelectAgent: (agentId: string) => void
isLoading?: boolean
}
function getAgentName(agentId: string): string {
const meta = DEV_AGENT_METADATA.find((a) => a.agent_id === agentId)
return meta?.agent_name || `Unknown Agent`
}
export function AgentSidebar({
agents,
selectedAgentId,
onSelectAgent,
isLoading,
}: AgentSidebarProps) {
const [search, setSearch] = useState("")
const agentsWithNames = useMemo(
() => agents.map((a) => ({ ...a, agent_name: getAgentName(a.agent_id) })),
[agents]
)
const fuse = useMemo(
() => new Fuse(agentsWithNames, { keys: ["agent_name", "agent_id"], threshold: 0.3 }),
[agentsWithNames]
)
const filteredAgents = useMemo(() => {
if (!search.trim()) return agentsWithNames
return fuse.search(search).map((result) => result.item)
}, [agentsWithNames, fuse, search])
if (isLoading) {
return (
<div className="w-48 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="h-8 bg-surface-2 rounded animate-pulse" />
</div>
<div className="flex-1 p-2 space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-surface-2 rounded animate-pulse" />
))}
</div>
</div>
)
}
return (
<div className="w-48 border-r border-border flex flex-col">
<div className="p-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search agents..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filteredAgents.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
{search ? "No matching agents" : "No agents with calls"}
</div>
) : (
filteredAgents.map((agent) => (
<button
key={agent.agent_id}
type="button"
onClick={() => onSelectAgent(agent.agent_id)}
className={cn(
"w-full p-2 text-left hover:bg-surface-2 transition-colors",
"border-b border-border/50",
selectedAgentId === agent.agent_id && "bg-surface-2 border-l-2 border-l-blue-500"
)}
>
<div className="flex items-center gap-2">
<Bot className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-medium truncate">{agent.agent_name}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 pl-5 font-mono">
{agent.agent_id.slice(0, 12)}... · {agent.call_count} calls
</div>
</button>
))
)}
</div>
</div>
)
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/components/AgentSidebar.tsx
git commit -m "feat(debug): add AgentSidebar component for dev env"
Phase 6: Frontend - Main Page Refactor¶
Task 16: Create DebugBreadcrumb Component¶
Files:
- Create: src/ui/modules/debug/components/DebugBreadcrumb.tsx
Step 1: Create the component
import { ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
type DebugBreadcrumbProps = {
orgName?: string
locationName?: string
agentName?: string
callId: string
onBack: () => void
env: "prod" | "dev"
}
export function DebugBreadcrumb({
orgName,
locationName,
agentName,
callId,
onBack,
env,
}: DebugBreadcrumbProps) {
return (
<div className="flex items-center gap-1 text-sm">
<Button variant="ghost" size="sm" onClick={onBack} className="h-7 px-2">
←
</Button>
{env === "prod" ? (
<>
<span className="text-muted-foreground">{orgName || "Unknown Org"}</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-muted-foreground">{locationName || "Unknown Location"}</span>
</>
) : (
<span className="text-muted-foreground">{agentName || "Unknown Agent"}</span>
)}
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="font-mono text-xs">{callId.slice(0, 20)}...</span>
</div>
)
}
Step 2: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 3: Commit
git add src/ui/modules/debug/components/DebugBreadcrumb.tsx
git commit -m "feat(debug): add DebugBreadcrumb component"
Task 17: Refactor CallsLandingPage - Part 1 (State & Layout)¶
Files:
- Modify: src/ui/modules/debug/pages/CallsLandingPage.tsx
Step 1: Read current implementation
Read the entire src/ui/modules/debug/pages/CallsLandingPage.tsx file to understand existing structure.
Step 2: Replace with new implementation - imports and state
Replace the file content with the new multi-sidebar implementation. This is a large change, so we'll do it in one step with careful testing.
import { useCallback, useEffect, useMemo, useState } from "react"
import { useNavigate, useSearchParams } from "react-router-dom"
import { DayNavigator } from "../components/DayNavigator"
import { OrgSidebar } from "../components/OrgSidebar"
import { LocationSidebar } from "../components/LocationSidebar"
import { CallsSidebar } from "../components/CallsSidebar"
import { AgentSidebar } from "../components/AgentSidebar"
import { DebugBreadcrumb } from "../components/DebugBreadcrumb"
import { useDayIndex } from "../hooks/useDayIndex"
import { useLocationCalls } from "../hooks/useLocationCalls"
import { useDevDayIndex } from "../hooks/useDevDayIndex"
import { CallDebugger } from "./CallDebugger"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { DayIndexOrg } from "../types/debugger"
type Environment = "prod" | "dev"
function getToday(): string {
return new Date().toISOString().split("T")[0]
}
export function CallsLandingPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
// URL state
const date = searchParams.get("date") || getToday()
const env: Environment = (searchParams.get("env") as Environment) || "prod"
// Selection state
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null)
const [selectedLocationId, setSelectedLocationId] = useState<string | null>(null)
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const [selectedCallId, setSelectedCallId] = useState<string | null>(null)
// Debounce timer for env switch
const [envSwitchTimer, setEnvSwitchTimer] = useState<NodeJS.Timeout | null>(null)
// Data hooks (prod)
const { data: dayIndex, isLoading: isLoadingIndex } = useDayIndex(
env === "prod" ? date : null
)
const selectedOrg = useMemo(
() => dayIndex?.orgs.find((o) => o.org_id === selectedOrgId) || null,
[dayIndex, selectedOrgId]
)
const {
calls: locationCalls,
hasMore,
isLoading: isLoadingCalls,
loadMore,
} = useLocationCalls(
env === "prod" ? selectedLocationId : null,
env === "prod" ? date : null
)
// Data hooks (dev)
const { data: devIndex, isLoading: isLoadingDevIndex } = useDevDayIndex(
env === "dev" ? date : null
)
// Group dev calls by agent_id
const devAgents = useMemo(() => {
if (!devIndex?.calls) return []
const agentMap = new Map<string, { agent_id: string; call_count: number; calls: typeof devIndex.calls }>()
for (const call of devIndex.calls) {
// Extract agent_id from call_id pattern or use "unknown"
// This is a placeholder - actual implementation depends on call_id format
const agentId = "agent_unknown" // TODO: Extract from call metadata
let agent = agentMap.get(agentId)
if (!agent) {
agent = { agent_id: agentId, call_count: 0, calls: [] }
agentMap.set(agentId, agent)
}
agent.calls.push(call)
agent.call_count++
}
return Array.from(agentMap.values()).sort((a, b) => b.call_count - a.call_count)
}, [devIndex])
const selectedDevAgent = useMemo(
() => devAgents.find((a) => a.agent_id === selectedAgentId) || null,
[devAgents, selectedAgentId]
)
// Auto-select first org/agent on load
useEffect(() => {
if (env === "prod" && dayIndex?.orgs.length && !selectedOrgId) {
setSelectedOrgId(dayIndex.orgs[0].org_id)
}
}, [env, dayIndex, selectedOrgId])
useEffect(() => {
if (env === "dev" && devAgents.length && !selectedAgentId) {
setSelectedAgentId(devAgents[0].agent_id)
}
}, [env, devAgents, selectedAgentId])
// Handlers
const handleDateChange = useCallback(
(newDate: string) => {
setSearchParams({ date: newDate, env })
setSelectedOrgId(null)
setSelectedLocationId(null)
setSelectedAgentId(null)
setSelectedCallId(null)
},
[env, setSearchParams]
)
const handleEnvChange = useCallback(
(newEnv: Environment) => {
// Clear any pending timer
if (envSwitchTimer) {
clearTimeout(envSwitchTimer)
}
// Debounce the actual switch
const timer = setTimeout(() => {
setSearchParams({ date, env: newEnv })
setSelectedOrgId(null)
setSelectedLocationId(null)
setSelectedAgentId(null)
setSelectedCallId(null)
}, 500)
setEnvSwitchTimer(timer)
},
[date, setSearchParams, envSwitchTimer]
)
const handleSelectOrg = useCallback((orgId: string) => {
setSelectedOrgId(orgId)
setSelectedLocationId(null)
setSelectedCallId(null)
}, [])
const handleSelectLocation = useCallback((locationId: string) => {
setSelectedLocationId(locationId)
setSelectedCallId(null)
}, [])
const handleSelectAgent = useCallback((agentId: string) => {
setSelectedAgentId(agentId)
setSelectedCallId(null)
}, [])
const handleSelectCall = useCallback((callId: string) => {
setSelectedCallId(callId)
}, [])
const handleBackFromDebugger = useCallback(() => {
setSelectedCallId(null)
}, [])
// Get names for breadcrumb
const selectedOrgName = selectedOrg?.org_name
const selectedLocationName = selectedOrg?.locations.find(
(l) => l.location_id === selectedLocationId
)?.location_name
const selectedAgentName = selectedDevAgent?.agent_id // TODO: Use actual name from metadata
// Render debugger view
if (selectedCallId) {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<DebugBreadcrumb
orgName={selectedOrgName}
locationName={selectedLocationName}
agentName={selectedAgentName}
callId={selectedCallId}
onBack={handleBackFromDebugger}
env={env}
/>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{date}
</div>
</div>
<div className="flex-1 overflow-hidden">
<CallDebugger
callId={selectedCallId}
date={date}
env={env}
/>
</div>
</div>
)
}
// Render browse view
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<DayNavigator date={date} onDateChange={handleDateChange} />
<div className="flex items-center gap-2">
<Button
variant={env === "prod" ? "default" : "outline"}
size="sm"
onClick={() => handleEnvChange("prod")}
className="h-7 text-xs"
>
Prod
</Button>
<Button
variant={env === "dev" ? "default" : "outline"}
size="sm"
onClick={() => handleEnvChange("dev")}
className="h-7 text-xs"
>
Dev
</Button>
</div>
</div>
{/* Main content */}
<div className="flex-1 flex overflow-hidden">
{env === "prod" ? (
<>
<OrgSidebar
orgs={dayIndex?.orgs || []}
selectedOrgId={selectedOrgId}
onSelectOrg={handleSelectOrg}
isLoading={isLoadingIndex}
/>
<LocationSidebar
locations={selectedOrg?.locations || []}
selectedLocationId={selectedLocationId}
onSelectLocation={handleSelectLocation}
isLoading={isLoadingIndex && !!selectedOrgId}
/>
<CallsSidebar
calls={locationCalls}
selectedCallId={selectedCallId}
onSelectCall={handleSelectCall}
hasMore={hasMore}
onLoadMore={loadMore}
isLoading={isLoadingCalls}
/>
</>
) : (
<>
<AgentSidebar
agents={devAgents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
isLoading={isLoadingDevIndex}
/>
<CallsSidebar
calls={selectedDevAgent?.calls.map((c) => ({ ...c, call_id: c.call_id })) || []}
selectedCallId={selectedCallId}
onSelectCall={handleSelectCall}
isLoading={isLoadingDevIndex && !!selectedAgentId}
/>
</>
)}
{/* Empty state / instructions */}
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{env === "prod" ? (
!selectedLocationId ? (
"Select an org and location to view calls"
) : locationCalls.length === 0 && !isLoadingCalls ? (
"No calls for this location"
) : (
"Select a call to view details"
)
) : (
!selectedAgentId ? (
"Select an agent to view calls"
) : (selectedDevAgent?.calls.length || 0) === 0 && !isLoadingDevIndex ? (
"No calls for this agent"
) : (
"Select a call to view details"
)
)}
</div>
</div>
</div>
)
}
Step 3: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds (may have warnings about CallDebugger props that we'll fix next)
Step 4: Commit
git add src/ui/modules/debug/pages/CallsLandingPage.tsx
git commit -m "feat(debug): refactor CallsLandingPage with multi-sidebar layout"
Task 18: Update CallDebugger Props¶
Files:
- Modify: src/ui/modules/debug/pages/CallDebugger.tsx
Step 1: Read current CallDebugger implementation
Read src/ui/modules/debug/pages/CallDebugger.tsx to understand current props.
Step 2: Update to accept props from parent
The CallDebugger currently reads from URL params. We need to allow it to accept props directly when embedded in CallsLandingPage. Add prop types and use props when provided, falling back to URL params.
Add to the component:
type CallDebuggerProps = {
callId?: string
date?: string
env?: string
}
export function CallDebugger({ callId: propCallId, date: propDate, env: propEnv }: CallDebuggerProps = {}) {
// Use props if provided, otherwise fall back to URL params
const params = useParams()
const callId = propCallId || params.callId || ""
const date = propDate || params.date || ""
const env = propEnv || params.env || "prod"
// ... rest of component
}
Step 3: Verify build
Run: devenv shell -- pnpm build
Expected: Build succeeds
Step 4: Test the UI
Run: devenv shell -- pnpm dev
Navigate to http://localhost:5173/debug
Expected: Multi-sidebar layout appears, can navigate between orgs/locations
Step 5: Commit
git add src/ui/modules/debug/pages/CallDebugger.tsx
git commit -m "feat(debug): update CallDebugger to accept props"
Phase 7: Testing & Polish¶
Task 19: Manual Integration Test¶
Step 1: Start dev server
Run: devenv shell -- pnpm dev
Step 2: Test prod environment
- Navigate to
http://localhost:5173/debug - Verify prod is selected by default
- Verify orgs sidebar loads with fuzzy search
- Click an org, verify locations sidebar populates
- Click a location, verify calls sidebar populates with pagination
- Click a call, verify debugger opens with breadcrumb
- Click back button, verify return to browse view
- Test day navigation arrows
Step 3: Test dev environment
- Click "Dev" button
- Verify agents sidebar loads (may be empty or show "agent_unknown")
- Click an agent, verify calls sidebar populates
- Test search functionality
Step 4: Test edge cases
- Navigate to a date with no calls - verify empty state
- Rapidly switch between prod/dev - verify debounce works
- Test fuzzy search in each sidebar
Step 5: Document any issues found
Note any bugs or issues for follow-up tasks.
Task 20: Run Full Check¶
Step 1: Run pnpm check
Run: devenv shell -- pnpm check
Expected: All checks pass (biome, tsc, build, deploy --dry-run)
Step 2: Fix any issues
If any issues found, fix them and re-run check.
Step 3: Final commit
Phase 8: Cleanup & PR¶
Task 21: Create PR¶
Step 1: Push branch
Run: git push -u origin debugger-dashboard
Step 2: Create PR
Run: gh pr create --fill
Step 3: Link to Linear ticket
Add "Fixes ARE-1557" to PR description if not already included.
Summary¶
| Phase | Tasks | Description |
|---|---|---|
| 1 | 1-4 | Backend prod routes (day index, location calls) |
| 2 | 5 | Backend dev routes (S3 index) |
| 3 | 6 | Shared TypeScript types |
| 4 | 7-9 | Data fetching hooks |
| 5 | 10-15 | UI components (sidebars, nav) |
| 6 | 16-18 | Main page refactor |
| 7 | 19-20 | Testing and polish |
| 8 | 21 | PR creation |
Total tasks: 21 Estimated commits: ~18