From cdbbade0aa4bf0c192f6750df8ff1ae80498e013 Mon Sep 17 00:00:00 2001 From: Marty Date: Mon, 12 Jan 2026 19:38:24 +0000 Subject: [PATCH] feat: Add mobile-responsive UI with remote access support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a fully responsive mobile-friendly interface that adapts to different screen sizes while maintaining the existing desktop Kanban board experience. ## Mobile View (<1024px) **New Components:** - RepoDropdown: Dropdown navigation showing repos with colored status emoji indicators (🟢 working, 🟠 needs approval, 🟡 waiting, ⚪ idle) - StatusFilter: Chip-based status filters for quick filtering - SessionCardCompact: Touch-optimized session cards for list view - SessionDetailModal: Full-screen modal for viewing session details **Features:** - Dropdown shows aggregated status emojis for each repo - Filter sessions by both repo AND status simultaneously - Vertical scrollable list of sessions (sorted by activity) - Tap any session card to view full details in modal - Touch-friendly 44px minimum tap targets - No horizontal scrolling required ## Desktop View (≥1024px) - Preserves existing 4-column Kanban board layout - Hover cards for session details - No changes to current desktop experience ## Remote Access Support **Daemon (server.ts):** - Bind to 0.0.0.0 by default (configurable via HOST env var) - Allows connections from remote clients, not just localhost **UI (sessionsDb.ts):** - Dynamic stream URL based on browser location - Automatically connects to daemon on same host as web UI - Supports both local (127.0.0.1) and remote access ## Technical Details - CSS breakpoint: 1024px - Uses CSS media queries to toggle between layouts - Mobile-first approach with progressive enhancement - Maintains all existing functionality - Zero breaking changes to desktop experience ## Testing Tested on: - Desktop browsers (Chrome, Firefox) - Mobile responsive mode (Chrome DevTools) - Remote access via SSH tunnel Closes: N/A (enhancement) --- packages/daemon/src/server.ts | 8 +- packages/ui/src/components/RepoDropdown.tsx | 44 +++ .../ui/src/components/SessionCardCompact.tsx | 133 +++++++++ .../ui/src/components/SessionDetailModal.tsx | 258 ++++++++++++++++++ packages/ui/src/components/StatusFilter.tsx | 48 ++++ packages/ui/src/data/sessionsDb.ts | 4 +- packages/ui/src/index.css | 63 +++++ packages/ui/src/routes/index.tsx | 156 ++++++++++- 8 files changed, 697 insertions(+), 17 deletions(-) create mode 100644 packages/ui/src/components/RepoDropdown.tsx create mode 100644 packages/ui/src/components/SessionCardCompact.tsx create mode 100644 packages/ui/src/components/SessionDetailModal.tsx create mode 100644 packages/ui/src/components/StatusFilter.tsx diff --git a/packages/daemon/src/server.ts b/packages/daemon/src/server.ts index ee6101d..bfefd1b 100644 --- a/packages/daemon/src/server.ts +++ b/packages/daemon/src/server.ts @@ -29,19 +29,21 @@ export class StreamServer { constructor(options: StreamServerOptions = {}) { this.port = options.port ?? DEFAULT_PORT; + const host = process.env.HOST ?? "0.0.0.0"; // Use in-memory storage during development (no dataDir = in-memory) this.server = new DurableStreamTestServer({ port: this.port, - host: "127.0.0.1", + host, }); - this.streamUrl = `http://127.0.0.1:${this.port}${SESSIONS_STREAM_PATH}`; + this.streamUrl = `http://${host}:${this.port}${SESSIONS_STREAM_PATH}`; } async start(): Promise { await this.server.start(); - log("Server", `Durable Streams server running on http://127.0.0.1:${this.port}`); + const host = process.env.HOST ?? "0.0.0.0"; + log("Server", `Durable Streams server running on http://${host}:${this.port}`); // Create or connect to the sessions stream try { diff --git a/packages/ui/src/components/RepoDropdown.tsx b/packages/ui/src/components/RepoDropdown.tsx new file mode 100644 index 0000000..435bbe5 --- /dev/null +++ b/packages/ui/src/components/RepoDropdown.tsx @@ -0,0 +1,44 @@ +import { Select } from "@radix-ui/themes"; + +interface RepoOption { + repoId: string; + repoUrl: string | null; + sessionCount: number; + statuses: Set; // 'working', 'waiting', 'idle', 'needs-approval' +} + +interface RepoDropdownProps { + repos: RepoOption[]; + selectedRepo: string; + onSelectRepo: (repoId: string) => void; +} + +function getStatusEmojis(statuses: Set): string { + const emojis: string[] = []; + if (statuses.has("working")) emojis.push("🟢"); + if (statuses.has("needs-approval")) emojis.push("🟠"); + if (statuses.has("waiting")) emojis.push("🟡"); + if (statuses.has("idle") && emojis.length === 0) emojis.push("⚪"); + return emojis.join(""); +} + +export function RepoDropdown({ repos, selectedRepo, onSelectRepo }: RepoDropdownProps) { + return ( + + + + All Repos + {repos.map((repo) => ( + + {getStatusEmojis(repo.statuses)} {repo.repoId} ({repo.sessionCount}) + + ))} + + + ); +} + +export type { RepoOption }; diff --git a/packages/ui/src/components/SessionCardCompact.tsx b/packages/ui/src/components/SessionCardCompact.tsx new file mode 100644 index 0000000..d343df8 --- /dev/null +++ b/packages/ui/src/components/SessionCardCompact.tsx @@ -0,0 +1,133 @@ +import { Card, Flex, Text, Code, Badge } from "@radix-ui/themes"; +import type { Session } from "../data/schema"; + +interface SessionCardCompactProps { + session: Session; + onClick?: () => void; +} + +function getStatusEmoji(session: Session): string { + if (session.status === "working") return "🟢"; + if (session.status === "waiting" && session.hasPendingToolUse) return "🟠"; + if (session.status === "waiting") return "🟡"; + return "⚪"; +} + +function formatTimeAgo(isoString: string): string { + const now = Date.now(); + const then = new Date(isoString).getTime(); + const diff = now - then; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return `${seconds}s ago`; +} + +function getCIStatusColor(status: string): "green" | "red" | "yellow" | "gray" { + switch (status) { + case "success": + return "green"; + case "failure": + return "red"; + case "running": + case "pending": + return "yellow"; + default: + return "gray"; + } +} + +function getCIStatusIcon(status: string): string { + switch (status) { + case "success": + return "✓"; + case "failure": + return "✗"; + case "running": + case "pending": + return "◎"; + default: + return "?"; + } +} + +export function SessionCardCompact({ session, onClick }: SessionCardCompactProps) { + const repoName = session.gitRepoId || "Other"; + const branch = session.gitBranch || "no branch"; + + return ( + + + {/* Header: status + repo/branch + time */} + + + {getStatusEmoji(session)} + + {repoName} • {branch.length > 20 ? branch.slice(0, 17) + "..." : branch} + + + + {formatTimeAgo(session.lastActivityAt)} + + + + {/* Goal/prompt */} + + {session.goal || session.originalPrompt.slice(0, 60)} + + + {/* Summary or pending tool */} + + {session.hasPendingToolUse && session.pendingTool ? ( + <> + ⚠️ {session.pendingTool.tool}: {session.pendingTool.target.slice(0, 40)} + + ) : ( + session.summary + )} + + + {/* Footer: PR/branch + message count */} + + + {session.pr ? ( + e.stopPropagation()} + style={{ textDecoration: "none" }} + > + + {getCIStatusIcon(session.pr.ciStatus)} PR #{session.pr.number} + + + ) : session.gitBranch ? ( + + [{session.gitBranch.slice(0, 20)}] + + ) : null} + + + {session.messageCount} msgs + + + + + ); +} diff --git a/packages/ui/src/components/SessionDetailModal.tsx b/packages/ui/src/components/SessionDetailModal.tsx new file mode 100644 index 0000000..fc1e989 --- /dev/null +++ b/packages/ui/src/components/SessionDetailModal.tsx @@ -0,0 +1,258 @@ +import { Dialog, Flex, Heading, Text, Box, Badge, Code, Separator, Blockquote, ScrollArea } from "@radix-ui/themes"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import type { Session } from "../data/schema"; + +const codeTheme = { + ...oneDark, + 'comment': { ...oneDark['comment'], color: '#8b949e' }, + 'prolog': { ...oneDark['prolog'], color: '#8b949e' }, + 'doctype': { ...oneDark['doctype'], color: '#8b949e' }, + 'cdata': { ...oneDark['cdata'], color: '#8b949e' }, +}; + +interface SessionDetailModalProps { + session: Session | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function getRoleColor(role: "user" | "assistant" | "tool"): string { + switch (role) { + case "user": + return "var(--blue-11)"; + case "assistant": + return "var(--gray-12)"; + case "tool": + return "var(--violet-11)"; + } +} + +function getCIStatusColor(status: string): "green" | "red" | "yellow" | "gray" { + switch (status) { + case "success": + return "green"; + case "failure": + return "red"; + case "running": + case "pending": + return "yellow"; + default: + return "gray"; + } +} + +function getCIStatusIcon(status: string): string { + switch (status) { + case "success": + return "✓"; + case "failure": + return "✗"; + case "running": + case "pending": + return "◎"; + default: + return "?"; + } +} + +export function SessionDetailModal({ session, open, onOpenChange }: SessionDetailModalProps) { + if (!session) return null; + + return ( + + + + {session.goal || session.originalPrompt.slice(0, 60)} + + + + {session.cwd.replace(/^\/Users\/\w+\//, "~/")} + + + + + {/* Recent output */} + + {session.recentOutput?.length > 0 ? ( + session.recentOutput.map((output, i) => ( + + {output.role === "user" && ( + <> + + + You: + + + )} + ( + + {children} + + ), + code: ({ className, children }) => { + const match = /language-(\w+)/.exec(className || ""); + const isBlock = Boolean(match); + return isBlock ? ( + + {String(children).replace(/\n$/, "")} + + ) : ( + {children} + ); + }, + pre: ({ children }) => {children}, + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) => ( +
  • + {children} +
  • + ), + h1: ({ children }) => ( + + {children} + + ), + h2: ({ children }) => ( + + {children} + + ), + h3: ({ children }) => ( + + {children} + + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {output.content} +
    + {output.role === "user" && ( + + )} +
    + )) + ) : ( + + No recent output + + )} + {session.status === "working" && ( + + █ + + )} +
    + + {/* PR Info if available */} + {session.pr && ( + + + + PR #{session.pr.number}: {session.pr.title} + + + {session.pr.ciChecks.length > 0 && ( + + {session.pr.ciChecks.map((check) => ( + + {getCIStatusIcon(check.status)}{" "} + {check.name.slice(0, 20)} + + ))} + + )} + + )} + + {/* Session metadata */} + + + {session.messageCount} messages + + + {session.sessionId.slice(0, 8)} + + +
    +
    +
    +
    + ); +} diff --git a/packages/ui/src/components/StatusFilter.tsx b/packages/ui/src/components/StatusFilter.tsx new file mode 100644 index 0000000..391957e --- /dev/null +++ b/packages/ui/src/components/StatusFilter.tsx @@ -0,0 +1,48 @@ +import { Flex, Badge } from "@radix-ui/themes"; + +type FilterStatus = "all" | "working" | "needs-approval" | "waiting" | "idle"; + +interface StatusFilterProps { + activeFilter: FilterStatus; + onFilterChange: (filter: FilterStatus) => void; + counts: Record; +} + +const filterConfig: Array<{ + value: FilterStatus; + label: string; + emoji: string; + color: "gray" | "green" | "orange" | "yellow"; +}> = [ + { value: "all", label: "All", emoji: "", color: "gray" }, + { value: "working", label: "Working", emoji: "🟢", color: "green" }, + { value: "needs-approval", label: "Approval", emoji: "🟠", color: "orange" }, + { value: "waiting", label: "Waiting", emoji: "🟡", color: "yellow" }, + { value: "idle", label: "Idle", emoji: "⚪", color: "gray" }, +]; + +export function StatusFilter({ activeFilter, onFilterChange, counts }: StatusFilterProps) { + return ( + + {filterConfig.map((filter) => { + const count = counts[filter.value]; + const isActive = activeFilter === filter.value; + + return ( + onFilterChange(filter.value)} + > + {filter.emoji} {filter.label} {count > 0 && `(${count})`} + + ); + })} + + ); +} + +export type { FilterStatus }; diff --git a/packages/ui/src/data/sessionsDb.ts b/packages/ui/src/data/sessionsDb.ts index 385f29e..fe07c9e 100644 --- a/packages/ui/src/data/sessionsDb.ts +++ b/packages/ui/src/data/sessionsDb.ts @@ -1,7 +1,9 @@ import { createStreamDB, type StreamDB } from "@durable-streams/state"; import { sessionsStateSchema } from "./schema"; -const STREAM_URL = "http://127.0.0.1:4450/sessions"; +// Use environment variable if set, otherwise construct from window.location +const STREAM_URL = import.meta.env.VITE_STREAM_URL ?? + `${window.location.protocol}//${window.location.hostname}:4450/sessions`; export type SessionsDB = StreamDB; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 61e6bc9..f933491 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -92,3 +92,66 @@ body { .session-card:nth-child(3) { animation-delay: 100ms; } .session-card:nth-child(4) { animation-delay: 150ms; } .session-card:nth-child(5) { animation-delay: 200ms; } + +/* Responsive layout: Mobile vs Desktop */ +/* Mobile: < 1024px - Show list view */ +/* Desktop: >= 1024px - Show Kanban board */ + +/* Default: Hide desktop, show mobile */ +.desktop-view { + display: none; +} + +.mobile-view { + display: block; +} + +/* Desktop breakpoint: >= 1024px */ +@media (min-width: 1024px) { + .desktop-view { + display: flex; + } + + .mobile-view { + display: none; + } +} + +/* Mobile-specific styles */ +.mobile-view { + max-width: 100%; + overflow-x: hidden; +} + +.session-card-compact { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.session-card-compact:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.session-card-compact:active { + transform: translateY(0); +} + +/* Status filter chips */ +.status-filter { + padding: 4px 0; +} + +/* Dropdown styling */ +.mobile-dropdown-trigger { + min-height: 44px; + font-size: 16px; +} + +/* Touch-friendly minimum sizes */ +@media (max-width: 1023px) { + /* Ensure touch targets are at least 44x44px */ + button, .session-card-compact { + min-height: 44px; + } +} diff --git a/packages/ui/src/routes/index.tsx b/packages/ui/src/routes/index.tsx index db59496..b11d0f1 100644 --- a/packages/ui/src/routes/index.tsx +++ b/packages/ui/src/routes/index.tsx @@ -1,15 +1,36 @@ import { createFileRoute } from "@tanstack/react-router"; -import { Flex, Text } from "@radix-ui/themes"; -import { useEffect, useState } from "react"; +import { Flex, Text, Box } from "@radix-ui/themes"; +import { useEffect, useState, useMemo } from "react"; import { RepoSection } from "../components/RepoSection"; import { useSessions, groupSessionsByRepo } from "../hooks/useSessions"; +import { RepoDropdown, type RepoOption } from "../components/RepoDropdown"; +import { StatusFilter, type FilterStatus } from "../components/StatusFilter"; +import { SessionCardCompact } from "../components/SessionCardCompact"; +import { SessionDetailModal } from "../components/SessionDetailModal"; +import type { Session, SessionStatus } from "../data/schema"; export const Route = createFileRoute("/")({ component: IndexPage, }); +function getEffectiveStatus(session: Session): SessionStatus | "needs-approval" { + const elapsed = Date.now() - new Date(session.lastActivityAt).getTime(); + const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour + + if (elapsed > IDLE_TIMEOUT_MS) { + return "idle"; + } + if (session.status === "waiting" && session.hasPendingToolUse) { + return "needs-approval"; + } + return session.status; +} + function IndexPage() { const { sessions } = useSessions(); + const [selectedRepo, setSelectedRepo] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [selectedSession, setSelectedSession] = useState(null); // Force re-render every minute to update relative times and activity scores const [, setTick] = useState(0); @@ -18,6 +39,69 @@ function IndexPage() { return () => clearInterval(interval); }, []); + // Build repo options for dropdown + const repoOptions = useMemo((): RepoOption[] => { + const repoMap = new Map(); + + sessions.forEach((session) => { + const repoId = session.gitRepoId || "Other"; + if (!repoMap.has(repoId)) { + repoMap.set(repoId, { + repoId, + repoUrl: session.gitRepoUrl, + sessionCount: 0, + statuses: new Set(), + }); + } + + const repo = repoMap.get(repoId)!; + repo.sessionCount++; + repo.statuses.add(getEffectiveStatus(session)); + }); + + return Array.from(repoMap.values()).sort((a, b) => + b.sessionCount - a.sessionCount + ); + }, [sessions]); + + // Filter sessions for mobile view + const filteredSessions = useMemo(() => { + let filtered = sessions; + + // Filter by repo + if (selectedRepo !== "all") { + filtered = filtered.filter((s) => + (s.gitRepoId || "Other") === selectedRepo + ); + } + + // Filter by status + if (statusFilter !== "all") { + filtered = filtered.filter((s) => + getEffectiveStatus(s) === statusFilter + ); + } + + // Sort by last activity + return filtered.sort((a, b) => + new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime() + ); + }, [sessions, selectedRepo, statusFilter]); + + // Calculate status counts for filter badges + const statusCounts = useMemo(() => { + const base = selectedRepo === "all" ? sessions : + sessions.filter((s) => (s.gitRepoId || "Other") === selectedRepo); + + return { + all: base.length, + working: base.filter((s) => getEffectiveStatus(s) === "working").length, + "needs-approval": base.filter((s) => getEffectiveStatus(s) === "needs-approval").length, + waiting: base.filter((s) => getEffectiveStatus(s) === "waiting").length, + idle: base.filter((s) => getEffectiveStatus(s) === "idle").length, + }; + }, [sessions, selectedRepo]); + if (sessions.length === 0) { return ( @@ -34,16 +118,62 @@ function IndexPage() { const repoGroups = groupSessionsByRepo(sessions); return ( - - {repoGroups.map((group) => ( - - ))} - + <> + {/* Mobile view */} + + + {/* Repo dropdown */} + + + {/* Status filter chips */} + + + {/* Session list */} + + {filteredSessions.length === 0 ? ( + + No sessions match the current filters + + ) : ( + filteredSessions.map((session) => ( + setSelectedSession(session)} + /> + )) + )} + + + + + {/* Desktop view (original Kanban) */} + + {repoGroups.map((group) => ( + + ))} + + + {/* Session detail modal (mobile only) */} + !open && setSelectedSession(null)} + /> + ); }