Session Interface
Real-time workspace for solver-customer support sessions with timer, chat, and notes
Session Interface
The session interface is where the actual support work happens. When a solver claims a ticket and starts a session, they're presented with a unified workspace containing a timer, real-time chat, and notes panel. This interface is designed to help solvers stay focused and organized while providing excellent customer support.
Why This Design?
The PRD emphasizes that solvers need tools to track their time (they're paid per session tier), communicate with customers, and document what was done. The three-column layout on desktop gives equal weight to each concern:
- Timer - Tracks billable time with tier limit warnings
- Chat - Real-time communication with the customer via Ably
- Notes - Documentation for the solver's reference and session records
On mobile, these stack vertically with the timer always visible at the top.
Session Lifecycle
Sessions follow a state machine with four states:
The not_started state exists because a session is created when a solver starts it from a claimed ticket, but the billable timer doesn't begin until they explicitly click "Start". This gives solvers a moment to review the ticket details before the clock starts.
Database Schema
The sessions table tracks timing and state:
export const sessions = pgTable("sessions", {
id: uuid("id").primaryKey().defaultRandom(),
ticketId: uuid("ticket_id").notNull().references(() => tickets.id),
solverId: uuid("solver_id").notNull().references(() => solvers.id),
status: sessionStatusEnum("status").notNull().default("not_started"),
duration: integer("duration").notNull().default(0), // Elapsed seconds
timerStartedAt: timestamp("timer_started_at"), // When timer last started
tier: ticketTierEnum("tier").notNull().default("standard"),
notes: text("notes"),
startedAt: timestamp("started_at"), // First time timer started
endedAt: timestamp("ended_at"), // When session completed
// ... pricing, payment fields
});The duration field stores accumulated seconds. When the timer is running, timerStartedAt marks when it started. The actual elapsed time is duration + (now - timerStartedAt).
Sessions Router
The tRPC router handles all session operations with proper authorization:
export const sessionsRouter = router({
start: protectedProcedure
.input(z.object({ ticketId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
// Verify ticket is claimed by this solver
// Create session with status "not_started"
// Update ticket status to "in_progress"
}),
updateStatus: protectedProcedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(["active", "paused", "completed"]),
}))
.mutation(async ({ ctx, input }) => {
// Validate state transition is allowed
// Calculate elapsed time when pausing/completing
// Update ticket status when completing
}),
updateNotes: protectedProcedure
.input(z.object({ id: z.string().uuid(), notes: z.string() }))
.mutation(/* Save notes */),
syncTimer: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(/* Persist current elapsed time to DB */),
});State Transition Validation
The router enforces valid state transitions:
const VALID_TRANSITIONS: Record<SessionStatus, SessionStatus[]> = {
not_started: ["active"],
active: ["paused", "completed"],
paused: ["active", "completed"],
completed: [], // Terminal state
};Attempting an invalid transition (like going from completed back to active) returns a BAD_REQUEST error.
Timer Component
The SessionTimer component handles the visual display and controls:
export function SessionTimer({
initialSeconds,
status,
tierLimit,
onStart,
onPause,
onSync,
}: SessionTimerProps) {
const [elapsed, setElapsed] = useState(initialSeconds);
const isRunning = status === "active";
// Increment every second when running
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, [isRunning]);
// Sync to server every minute
useEffect(() => {
if (!isRunning || !onSync) return;
const syncInterval = setInterval(onSync, 60000);
return () => clearInterval(syncInterval);
}, [isRunning, onSync]);
// Keyboard shortcut: Space to toggle
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === "Space" && !isInputElement(e.target)) {
e.preventDefault();
isRunning ? onPause() : onStart();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isRunning, onStart, onPause]);
}Tier Limit Warnings
Each pricing tier has a time limit:
| Tier | Time Limit | Warning At |
|---|---|---|
| Quick | 20 minutes | 16 minutes |
| Standard | 45 minutes | 36 minutes |
| Extended | 90 minutes | 72 minutes |
The timer turns orange when approaching the limit (80%) and red when exceeded.
Notes Component
The SessionNotes component provides auto-saving with visual feedback:
export function SessionNotes({ initialNotes, onSave }: SessionNotesProps) {
const [notes, setNotes] = useState(initialNotes);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
// Auto-save every 30 seconds if changed
useEffect(() => {
const interval = setInterval(() => {
if (hasChanges) save();
}, 30000);
return () => clearInterval(interval);
}, []);
// Debounced save 5 seconds after typing stops
const handleChange = (value: string) => {
setNotes(value);
clearTimeout(saveTimeout);
saveTimeout = setTimeout(save, 5000);
};
// Keyboard shortcut: Cmd+S to save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
save();
}
};
// ...
}, [save]);
}Save status is displayed in the header: "Saving...", "Saved", or "Save failed".
Chat Integration
The session interface wraps content in an AblyProvider to enable real-time chat:
export function SessionInterface({ session }: Props) {
return (
<AblyProvider authUrl="/api/ably/auth-solver">
<div className="grid gap-6 lg:grid-cols-3">
<SessionTimer {...timerProps} />
<ChatWindow
sessionId={session.id}
userId={session.solver.user.clerkId}
userName={session.solver.user.name}
userRole="solver"
/>
<SessionNotes {...notesProps} />
</div>
</AblyProvider>
);
}The solver uses the /api/ably/auth-solver endpoint which grants elevated permissions to publish queue updates.
Session Page
The session detail page lives at /session/[id]:
export default function SessionPage() {
const params = useParams();
const sessionId = params.id as string;
const { data: session, isLoading, refetch } = trpc.sessions.get.useQuery(
{ id: sessionId },
{ refetchInterval: 60000 } // Sync with server every minute
);
if (isLoading) return <Loader />;
if (!session) return <NotFound />;
return <SessionInterface session={session} onRefetch={refetch} />;
}The page refetches every minute to stay in sync with any server-side changes.
Completing a Session
Session completion involves selecting a pricing tier and confirming the final charge. This is handled through a dedicated completion modal that presents the solver with all relevant information before finalizing.
Pricing Tiers
SavvySolve uses three pricing tiers, each with included time and overage rates:
| Tier | Base Price | Included Time | Overage Rate |
|---|---|---|---|
| Quick Assist | $69 | 20 minutes | $3/min |
| Standard Solve | $129 | 45 minutes | $2.50/min |
| Deep Dive | $219 | 90 minutes | $2/min |
The tier selector component calculates the recommended tier based on elapsed time and shows overage warnings when the session exceeds a tier's included time.
TierSelector Component
The TierSelector component handles tier selection with pricing calculations:
export const TIER_CONFIG = {
quick: {
name: "Quick Assist",
price: 6900, // cents
includedTime: 20 * 60, // seconds
overageRate: 300, // cents per minute
},
standard: { /* ... */ },
extended: { /* ... */ },
};
export function getRecommendedTier(elapsedSeconds: number): TierType {
if (elapsedSeconds <= TIER_CONFIG.quick.includedTime) return "quick";
if (elapsedSeconds <= TIER_CONFIG.standard.includedTime) return "standard";
return "extended";
}
export function calculateOverage(elapsedSeconds: number, tier: TierType) {
const config = TIER_CONFIG[tier];
const overageSeconds = Math.max(0, elapsedSeconds - config.includedTime);
const overageMinutes = Math.ceil(overageSeconds / 60);
return {
hasOverage: overageSeconds > 0,
overageMinutes,
overageAmount: overageMinutes * config.overageRate,
};
}The component highlights the recommended tier with a badge and shows overage warnings in amber when the session duration exceeds the tier's included time.
Session Completion Modal
The SessionCompletionModal provides a confirmation flow before finalizing:
export function SessionCompletionModal({
open,
onClose,
onComplete,
session,
}: SessionCompletionModalProps) {
const recommendedTier = getRecommendedTier(session.elapsedSeconds);
const [selectedTier, setSelectedTier] = useState(recommendedTier);
const [showConfirmation, setShowConfirmation] = useState(false);
const totalPrice = calculateTotalPrice(session.elapsedSeconds, selectedTier);
return (
<Dialog open={open}>
{/* Session summary: customer, duration, description */}
{/* Notes preview (read-only) */}
<TierSelector
selectedTier={selectedTier}
onTierChange={setSelectedTier}
elapsedSeconds={session.elapsedSeconds}
/>
{/* Total price display */}
<Button onClick={() => setShowConfirmation(true)}>
Complete Session
</Button>
{/* Confirmation dialog prevents accidental completion */}
<AlertDialog open={showConfirmation}>
<p>Charge customer ${totalPrice}?</p>
<Button onClick={handleConfirm}>Yes, Complete Session</Button>
</AlertDialog>
</Dialog>
);
}The modal displays:
- Session summary - Customer name, duration, issue description
- Notes preview - Read-only view of session notes
- Tier selector - All three tiers with recommended badge
- Total price - Base price plus any overage
- Confirmation dialog - Prevents accidental completion
sessions.complete Procedure
The backend completion procedure handles pricing, earnings, and state updates:
complete: protectedProcedure
.input(z.object({
id: z.string().uuid(),
tier: z.enum(["quick", "standard", "extended"]),
}))
.mutation(async ({ ctx, input }) => {
// Verify ownership and session is completable
const session = await db.query.sessions.findFirst({
where: eq(sessions.id, input.id),
with: { solver: true, ticket: true },
});
// Calculate final elapsed time (handle active timer)
let finalDuration = session.duration;
if (session.status === "active" && session.timerStartedAt) {
finalDuration += Math.floor((Date.now() - session.timerStartedAt) / 1000);
}
// Calculate pricing breakdown
const pricing = calculateSessionPricing(finalDuration, input.tier);
// Update session with completion data
await db.update(sessions).set({
status: "completed",
tier: input.tier,
duration: finalDuration,
endedAt: new Date(),
pricing: {
basePrice: pricing.basePrice,
platformFee: pricing.platformFee,
solverEarnings: pricing.solverEarnings,
totalCharged: pricing.totalCharged,
},
paymentInfo: { status: "pending" },
});
// Update solver statistics
await db.update(solvers).set({
stats: {
...solver.stats,
totalSessions: solver.stats.totalSessions + 1,
totalEarnings: solver.stats.totalEarnings + pricing.solverEarnings,
},
});
// Broadcast completion event to customer
await publishToChannel(
CHANNEL_NAMES.sessionChat(session.id),
MESSAGE_EVENTS.SESSION_COMPLETED,
{ sessionId: session.id, tier: input.tier, totalCharged: pricing.totalCharged }
);
}),The procedure:
- Validates the solver owns the session
- Ensures the session is in a completable state (active or paused)
- Calculates final duration including any running timer
- Computes pricing with solver earnings (default 65% revenue share)
- Updates session, ticket, and solver statistics
- Broadcasts completion to the customer via Ably
Solver Earnings
Solver earnings are calculated as a percentage of the total charge:
const DEFAULT_SOLVER_REVENUE_SHARE = 65; // 65%
function calculateSessionPricing(elapsedSeconds: number, tier: TierType) {
const tierConfig = TIER_PRICING[tier];
const overageMinutes = Math.ceil(
Math.max(0, elapsedSeconds - tierConfig.includedTime) / 60
);
const totalCharged = tierConfig.price + (overageMinutes * tierConfig.overageRate);
const solverEarnings = Math.floor(totalCharged * 0.65);
const platformFee = totalCharged - solverEarnings;
return { basePrice: tierConfig.price, totalCharged, solverEarnings, platformFee };
}For a standard session without overage:
- Total charged: $129
- Solver earnings (65%): $83.85
- Platform fee (35%): $45.15
Integration with SessionInterface
The completion modal is integrated into the main session interface:
export function SessionInterface({ session, onRefetch }: Props) {
const [showCompletionModal, setShowCompletionModal] = useState(false);
const completeSession = trpc.sessions.complete.useMutation();
const handleComplete = () => setShowCompletionModal(true);
const handleFinalComplete = async (tier: TierType) => {
await completeSession.mutateAsync({ id: session.id, tier });
router.push("/queue?completed=true");
};
return (
<AblyProvider authUrl="/api/ably/auth-solver">
<SessionHeader onComplete={handleComplete} />
{/* Timer, Chat, Notes */}
<SessionCompletionModal
open={showCompletionModal}
onClose={() => setShowCompletionModal(false)}
onComplete={handleFinalComplete}
session={{
customerName: session.ticket.customerInfo.name,
description: session.ticket.description,
elapsedSeconds: session.elapsedTime,
notes: currentNotes,
}}
/>
</AblyProvider>
);
}Post-Completion State
After completion:
- Session status is set to
completed(terminal state) - Session is locked - no further notes or timer changes allowed
- Ticket status updates to
completed - Solver statistics are incremented
- Customer receives completion notification via Ably
- Solver is redirected to
/queue?completed=true
Testing
Key test scenarios for sessions:
- State transitions: Verify only valid transitions are allowed
- Timer accuracy: Elapsed time calculation is correct across pause/resume
- Authorization: Only assigned solver can update session
- Auto-save: Notes persist without explicit save action
Session URL and Customer Access
For ad-hoc sessions (created directly by the solver rather than from a customer-submitted ticket), the session header displays a shareable URL that the solver can send to the customer. This URL includes the session's access token, allowing the customer to join without authentication.
// Generate customer join URL with access token
const joinUrl = typeof window !== "undefined"
? `${window.location.origin}/join/${sessionId}?token=${accessToken}`
: `/join/${sessionId}?token=${accessToken}`;The URL is displayed in the session header with a copy button:
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2">
<Link2 className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<code className="text-xs text-muted-foreground truncate max-w-[300px]">
{joinUrl}
</code>
<Button variant="ghost" size="sm" onClick={copyJoinUrl}>
{copied ? <Check /> : <Copy />}
</Button>
</div>This is particularly useful when:
- Solver creates an ad-hoc session for a walk-in customer
- The customer needs to join the session chat from their own device
- The session link wasn't automatically sent via SMS
See Customer Session View for details on the customer-facing interface.
Communication Tools
The session header provides three communication channels for interacting with customers: Call, SMS, and Screen Share. These tools require customer contact information to function.
Call Button and Voice Controls
The session interface integrates voice calling through the useVoiceCall hook, which supports both WebRTC (in-app) and bridge calling modes.
const {
callState,
isCallInProgress,
audioLevels,
isMuted,
call,
hangup,
toggleMute,
} = useVoiceCall(session.id);
const handleCall = useCallback(async () => {
const customerPhone = session.ticket.customerInfo.phone;
if (!customerPhone) {
toast.error("No phone number available for this customer");
return;
}
await call(customerPhone);
}, [session.ticket.customerInfo.phone, call]);Call States
The hook tracks the following call states:
| State | Description |
|---|---|
idle | No call in progress |
connecting | WebRTC client connecting to Telnyx |
initiating | Call being placed |
ringing | Customer's phone is ringing |
active | Call connected |
ended | Call has ended |
error | Call failed |
Audio Level Visualization
During active calls, the CallControls component displays real-time audio levels:
function AudioLevelIndicator({ level, label, icon }: AudioLevelIndicatorProps) {
const bars = 5;
const activeBars = Math.round(level * bars);
return (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-0.5">{icon}<span>{label}</span></div>
<div className="flex items-end gap-0.5 h-6">
{Array.from({ length: bars }).map((_, i) => (
<div
key={i}
className={cn(
"w-1.5 rounded-sm",
i < activeBars ? "bg-emerald-500" : "bg-muted-foreground/20"
)}
style={{ height: `${((i + 1) / bars) * 100}%` }}
/>
))}
</div>
</div>
);
}The audio levels use the Web Audio API to analyze the microphone input (local) and remote audio stream, providing visual feedback that audio is flowing correctly.
Call Controls
When a call is active, the solver sees:
- Mute button - Toggles microphone on/off
- Hangup button - Ends the call
- Audio level indicators - Shows local (microphone) and remote (customer) audio levels
- Duration display - Time elapsed since call connected
SMS Dialog
Opens a composable SMS interface for sending text messages:
export function SmsDialog({ open, onClose, sessionId, customerName }: Props) {
const [message, setMessage] = useState("");
const sendSms = trpc.sms.send.useMutation();
const handleSend = async () => {
await sendSms.mutateAsync({
sessionId,
message,
});
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Send SMS to {customerName}</DialogTitle>
</DialogHeader>
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
/>
<Button onClick={handleSend}>Send Message</Button>
</DialogContent>
</Dialog>
);
}Screen Share Request
Requests the customer to share their screen:
const handleScreenShare = useCallback(async () => {
const peerId = `solver_${session.solver.id}_${Date.now()}`;
toast.loading("Requesting screen share...", { id: "screenshare" });
await requestScreenShare.mutateAsync({
sessionId: session.id,
solverPeerId: peerId,
});
toast.success("Screen share request sent! Waiting for customer to accept.", {
id: "screenshare",
});
}, [session.id, session.solver.id, requestScreenShare]);Screen share works for sessions in active, paused, or not_started states.
Customer Info Editing
Solvers can edit customer contact information directly from the session header using the CustomerInfoEditor component:
export function CustomerInfoEditor({
ticketId,
initialName,
initialPhone,
initialEmail,
onUpdate,
}: CustomerInfoEditorProps) {
const updateInfo = trpc.tickets.updateCustomerInfo.useMutation({
onSuccess: () => {
toast.success("Customer info updated");
onUpdate?.();
},
});
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm">
<User className="mr-2 h-4 w-4" />
{initialName}
{!initialPhone && <span className="text-orange-600">(no phone)</span>}
<Pencil className="ml-2 h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent>
{/* Name, phone, email inputs */}
<Button onClick={handleSave}>Save</Button>
</PopoverContent>
</Popover>
);
}The component shows a warning when the customer has no phone number, since Call and SMS features require it. This is backed by the tickets.updateCustomerInfo procedure:
updateCustomerInfo: protectedProcedure
.input(z.object({
ticketId: z.string().uuid(),
customerName: z.string().min(1).max(100).optional(),
customerPhone: z.string().optional(),
customerEmail: z.string().email().optional(),
}))
.mutation(async ({ ctx, input }) => {
// Verify solver owns this ticket
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, input.ticketId),
});
// Build update object with only changed fields
const updates: Record<string, unknown> = {};
if (input.customerName) {
updates.customerInfo = {
...ticket.customerInfo,
name: input.customerName,
};
}
// ... phone, email updates
await db.update(tickets).set(updates).where(eq(tickets.id, input.ticketId));
}),Real-Time Timer Synchronization
The timer synchronizes between solver and customer views using Ably's real-time messaging. Rather than broadcasting every second (which would hammer the WebSocket), the system uses event-based synchronization.
How It Works
- Solver-side: Timer runs locally, broadcasting status changes on start/pause
- Customer-side: Timer runs locally, syncing on status change events
- Latency compensation: Customer calculates network delay to stay in sync
When a solver starts or pauses the session, the updateStatus procedure broadcasts the current state:
updateStatus: protectedProcedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(["active", "paused", "completed"]),
}))
.mutation(async ({ ctx, input }) => {
// ... state transition logic, duration calculation
// Broadcast status change to customer
const now = new Date();
await publishToChannel(
CHANNEL_NAMES.sessionChat(input.id),
MESSAGE_EVENTS.SESSION_STATUS,
{
sessionId: input.id,
status: input.status,
elapsedSeconds: newDuration,
timestamp: now.toISOString(),
},
);
}),Customer-Side Sync
The customer view subscribes to status events and compensates for network latency:
const handleStatusUpdate = useCallback((message: {
data: { status: string; elapsedSeconds: number; timestamp: string }
}) => {
const { status, elapsedSeconds, timestamp } = message.data;
// Calculate time elapsed since server sent this message
const serverTime = new Date(timestamp).getTime();
const now = Date.now();
const networkDelay = Math.max(0, Math.floor((now - serverTime) / 1000));
setCurrentStatus(status);
// If timer is now active, add network delay to sync up
if (status === "active") {
setCurrentElapsed(elapsedSeconds + networkDelay);
} else {
setCurrentElapsed(elapsedSeconds);
}
}, []);
useChannel(
CHANNEL_NAMES.sessionChat(session.id),
MESSAGE_EVENTS.SESSION_STATUS,
handleStatusUpdate,
);This approach keeps timers synchronized within 1-2 seconds while minimizing WebSocket traffic.
My Sessions Page
The "My Sessions" page at /dashboard/sessions provides solvers with a complete history of their sessions, including active ones that can be resumed.
List Procedure
The sessions.list procedure powers the sessions list with cursor-based pagination:
list: protectedProcedure
.input(z.object({
limit: z.number().min(1).max(50).default(20),
cursor: z.string().uuid().optional(),
status: z.enum(["not_started", "active", "paused", "completed", "cancelled"]).optional(),
}))
.query(async ({ ctx, input }) => {
const { solver } = await getSolverForUser(ctx.auth.userId);
const results = await db.query.sessions.findMany({
where: and(
eq(sessions.solverId, solver.id),
input.status ? eq(sessions.status, input.status) : undefined,
input.cursor ? lt(sessions.createdAt, cursorSession.createdAt) : undefined,
),
with: {
ticket: true,
rating: true,
},
orderBy: [desc(sessions.createdAt)],
limit: input.limit + 1,
});
// Transform for client with earnings, customer info, etc.
return {
items: results.slice(0, input.limit).map(transformSession),
nextCursor: hasMore ? results[input.limit - 1].id : undefined,
};
})Session Cards
Each session in the list displays:
| Field | Description |
|---|---|
| Customer Name | From the linked ticket's customerInfo |
| Status Badge | Visual indicator with icon (Active, Paused, Completed, etc.) |
| Description | Brief excerpt of the original problem |
| Tier | Quick Assist, Standard Solve, or Deep Dive |
| Duration | Elapsed time in mm:ss format |
| Earnings | Solver's share for completed sessions |
| Rating | Customer rating (1-5 stars) if rated |
| Date | Relative time for active, absolute for completed |
Status Filtering
The page includes a dropdown to filter by session status:
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sessions</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="not_started">Not Started</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>Actions
Each session card provides a context-appropriate action button:
- Active/Paused/Not Started: "Resume" button - returns to the session interface
- Completed/Cancelled: "View" button - opens read-only session details
Infinite Scroll
The list uses tRPC's useInfiniteQuery with cursor-based pagination:
const { data, fetchNextPage, hasNextPage } = trpc.sessions.list.useInfiniteQuery(
{ limit: 20, status: statusFilter === "all" ? undefined : statusFilter },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
const allSessions = data?.pages.flatMap((page) => page.items) ?? [];A "Load More" button appears when more sessions are available.
Empty States
The page handles empty states gracefully:
- No sessions at all: Prompts solver to claim a ticket from the queue
- No sessions matching filter: Explains that no sessions match the selected status
Related Documentation
- Ably Real-Time Integration - WebSocket chat implementation
- Queue Feature - Where sessions originate
- Tickets Router - Ticket lifecycle management
- Customer Session View - Customer-facing interface
- Screen Sharing - Screen share implementation
- Solver Daily Workflow - How to navigate between dashboard, queue, and sessions