diff --git a/.env.example b/.env.example index b7a145e..63e3f46 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,17 @@ MCP_ENABLED=false # created on the first grant. Stores only token hashes. #TEAMWORK_AGENT_CLIENTS_PATH=~/.teamwork/agent-clients.json +# ─── Desktop (noVNC from the sandbox) ─────────────────────────────────────── +# Where TeamWork proxies the desktop panel from. Empty disables the panel. +# • TeamWork on the host, sandbox in Docker (the usual shape): +# DESKTOP_VNC_URL=http://127.0.0.1:6080 +# • TeamWork inside the sandbox's compose network: +# DESKTOP_VNC_URL=http://sandbox:6080 +# Unset, the desktop asset proxy 503s and the clipboard socket is refused — so +# set it whenever the sandbox is running, or the panel fails in a way that looks +# like an auth problem rather than a missing setting. +DESKTOP_VNC_URL= + # ─── Sandbox connection (terminal / browser / desktop) ────────────────────── # These tell TeamWork how to reach the agent's sandbox container for the # terminal (docker exec), browser (Chrome DevTools Protocol screencast), and diff --git a/.gitignore b/.gitignore index 657c4f6..149df27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,14 @@ # Environment variables .env +# Any .env derivative — backups especially. A dated copy made during an incident +# ("cp .env .env.bak-$(date +%s)") is a full set of live credentials sitting in +# the working tree, and it only has to be caught by one `git add -A` once. Prax +# already ignores `.env.bak*`; this repo did not, so the same habit was safe in +# one repo and a leak in the other. +.env.bak* +.env.backup* +.env.save +.env.orig .env.local .env.*.local .env.development diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a32c2d3..19f9942 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,17 @@ function App() { const root = document.documentElement.style; root.setProperty('--app-height', `${vv.height}px`); root.setProperty('--app-top', `${vv.offsetTop}px`); + + // Is the on-screen keyboard up? The visual viewport shrinks while the + // layout viewport does not, so the gap between them is the keyboard. + // 150px is comfortably more than the browser chrome that also comes and + // goes on scroll, and comfortably less than any real keyboard. + // + // This matters because the mobile tab bar is `fixed bottom-0`, which + // pins it to the LAYOUT bottom — so when the keyboard opens it sits + // squarely over the composer and you cannot see what you are typing. + const keyboardOpen = window.innerHeight - vv.height > 150; + document.documentElement.classList.toggle('keyboard-open', keyboardOpen); }; update(); vv.addEventListener('resize', update); diff --git a/frontend/src/__tests__/mobile-layout.test.ts b/frontend/src/__tests__/mobile-layout.test.ts new file mode 100644 index 0000000..4670bf5 --- /dev/null +++ b/frontend/src/__tests__/mobile-layout.test.ts @@ -0,0 +1,71 @@ +/** + * Structural guards for the mobile layout traps in this codebase. + * + * Three bugs shipped from the same two mistakes, and each was reported as + * "mobile is broken" rather than as anything a unit test would have caught: + * + * - an inline pixel width applies at EVERY breakpoint and outranks Tailwind, + * so a desktop resizer's value became the phone's column width — content + * wider than the screen, with a dead band beside it; + * - a flex child defaults to `min-height: auto`, so `flex-col overflow-hidden` + * without `min-h-0` refuses to shrink and CLIPS its content instead of + * letting the inner scroller work. + * + * These assert against the source because both are properties of the markup, and + * jsdom has no layout engine — it cannot tell you something is off-screen. A + * grep-shaped test is a weak test in general, but here it encodes exactly the + * mistake a future edit would repeat. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC = join(dirname(fileURLToPath(import.meta.url)), '..'); + +function tsxFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry); + if (statSync(full).isDirectory()) return tsxFiles(full); + return full.endsWith('.tsx') && !full.includes('.test.') ? [full] : []; + }); +} + +describe('mobile layout', () => { + it('never sets a pixel width inline (it would outrank w-full on phones)', () => { + const offenders: string[] = []; + for (const file of tsxFiles(SRC)) { + readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + // Percentages are fine — progress bars scale with their parent. + // Pixel values and bare numbers are the danger. + if (/style=\{\{\s*\[?['"]?(width|minWidth)/.test(line) && !line.includes('%')) { + offenders.push(`${file.replace(SRC, '')}:${i + 1}`); + } + }); + } + expect( + offenders, + 'pass the value as a CSS variable and consume it with a md: class, so ' + + 'mobile keeps w-full', + ).toEqual([]); + }); + + it('gives every clipping flex column a min-h-0 so it can scroll', () => { + const offenders: string[] = []; + for (const file of tsxFiles(SRC)) { + readFileSync(file, 'utf8').split('\n').forEach((line, i) => { + const isFlexCol = /flex-col/.test(line); + const clips = /overflow-hidden/.test(line); + const grows = /flex-1/.test(line); + if (isFlexCol && clips && grows && !/min-h-0/.test(line)) { + offenders.push(`${file.replace(SRC, '')}:${i + 1}`); + } + }); + } + expect( + offenders, + 'a flex child will not shrink below its content without min-h-0, so ' + + 'overflow-hidden clips instead of scrolling', + ).toEqual([]); + }); +}); diff --git a/frontend/src/components/chat/ClaudeCodeStatus.tsx b/frontend/src/components/chat/ClaudeCodeStatus.tsx index 625ea89..92af02d 100644 --- a/frontend/src/components/chat/ClaudeCodeStatus.tsx +++ b/frontend/src/components/chat/ClaudeCodeStatus.tsx @@ -55,7 +55,7 @@ export function ClaudeCodeStatus({ darkMode }: ClaudeCodeStatusProps) { {/* Expanded dropdown */} {expanded && ( -
{/* Mention popup */} {showMentions && filteredAgents.length > 0 && ( -
+
Team Members
diff --git a/frontend/src/components/chat/MessageList.tsx b/frontend/src/components/chat/MessageList.tsx index b5e35c2..75a8342 100644 --- a/frontend/src/components/chat/MessageList.tsx +++ b/frontend/src/components/chat/MessageList.tsx @@ -331,7 +331,9 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick, {time}
- +
+ +
{attachments.length > 0 && } @@ -355,7 +357,9 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick,
- +
+ +
{attachments.length > 0 && } @@ -369,7 +373,7 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick, {/* Quick reactions */}
diff --git a/frontend/src/components/common/ModelPicker.tsx b/frontend/src/components/common/ModelPicker.tsx index 594a90d..3a92b22 100644 --- a/frontend/src/components/common/ModelPicker.tsx +++ b/frontend/src/components/common/ModelPicker.tsx @@ -71,7 +71,7 @@ export function ModelPicker({ info, darkMode = false, onSelect }: Props) { darkMode ? 'text-gray-500' : 'text-gray-400'); return ( -
{/* Auto and the tiers stay pinned: they are the choices most people want, diff --git a/frontend/src/components/panels/BrowserPanel.tsx b/frontend/src/components/panels/BrowserPanel.tsx index 3251fbc..e639ee1 100644 --- a/frontend/src/components/panels/BrowserPanel.tsx +++ b/frontend/src/components/panels/BrowserPanel.tsx @@ -399,7 +399,7 @@ export function BrowserPanel({ projectId, isVisible, onClose }: BrowserPanelProp // and so the cast-receive WebSocket stays connected while the user goes // to the Desktop tab to click the prax-cast icon. return ( -
+
{/* Browser toolbar — wraps on very narrow (mobile) widths so the trailing actions (esp. the chat toggle) are never clipped off the right edge. */}
diff --git a/frontend/src/components/panels/DesktopPanel.tsx b/frontend/src/components/panels/DesktopPanel.tsx index 4ba11e0..9fae3cf 100644 --- a/frontend/src/components/panels/DesktopPanel.tsx +++ b/frontend/src/components/panels/DesktopPanel.tsx @@ -278,7 +278,7 @@ export function DesktopPanel({ projectId, isVisible, onClose }: Props) { }; return ( -
+
{/* Toolbar — wraps on narrow (mobile) widths so its many shortcut/ clipboard/chat/close buttons are never clipped off the right edge (matches BrowserPanel). The flex-1 label keeps the desktop layout diff --git a/frontend/src/components/panels/GraphPanel.tsx b/frontend/src/components/panels/GraphPanel.tsx index 7a1e4c4..c9d30ae 100644 --- a/frontend/src/components/panels/GraphPanel.tsx +++ b/frontend/src/components/panels/GraphPanel.tsx @@ -21,6 +21,7 @@ import { useExecutionGraphs, useAgentLiveOutput, useAgents, useDeleteExecutionGr import type { ExecutionGraph, GraphNode } from '@/hooks/useApi'; import { MarkdownContent } from '@/components/common'; import { useUIStore } from '@/stores'; +import { useHistoryState } from '@/hooks/useHistoryState'; import { GraphVisualView } from './GraphVisualView'; interface GraphPanelProps { @@ -172,6 +173,7 @@ function formatDuration(s: number): string { return sec > 0 ? `${m}m ${sec}s` : `${m}m`; } +/** Clock time only — for spans WITHIN a trace, where the date is the trace's. */ function formatTime(iso: string): string { try { const d = new Date(iso); @@ -179,6 +181,37 @@ function formatTime(iso: string): string { } catch { return iso; } } +/** + * Date AND time, for a trace in the list. + * + * Time alone was ambiguous the moment the list held more than one day of + * traces: "14:32" tells you nothing about whether that run was an hour ago or + * last Tuesday, and the list is exactly where you go to find a run you remember + * by when it happened. + * + * Today's runs keep a "Today" prefix so the common case stays scannable rather + * than becoming a wall of identical dates. + */ +function formatDateTime(iso: string): string { + try { + const d = new Date(iso); + const time = d.toLocaleTimeString([], { + hour: '2-digit', minute: '2-digit', second: '2-digit', + }); + const now = new Date(); + const sameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + if (sameDay) return `Today ${time}`; + + const date = d.toLocaleDateString([], { + year: 'numeric', month: 'short', day: 'numeric', + }); + return `${date}, ${time}`; + } catch { return iso; } +} + // --------------------------------------------------------------------------- // Tree node component // --------------------------------------------------------------------------- @@ -369,7 +402,17 @@ function NodeDetail({ const children = graph.nodes.filter((n) => n.parent_id === node.span_id); return ( -
+ // On a phone this is ONE scrolling column. It used to be a fixed-height + // flex stack, and the summary lost: an `overflow-auto` flex item has an + // automatic minimum size of zero, so it is the first thing crushed when + // space runs short, while the Delegated Spans list below it has visible + // overflow and refuses to shrink at all. The result was a one-line sliver + // of summary — not even tall enough to read a full letter — under a + // full-height list of span names. + // + // Sections keep their natural height and the column scrolls. The desktop + // pane layout is untouched at md, where there is height to divide. +
{/* Header */}
@@ -432,7 +475,7 @@ function NodeDetail({ {/* Children summary */} {children.length > 0 && ( -

+
@@ -570,8 +613,11 @@ function GraphListItem({
)} {rootNode && ( -
- {formatTime(rootNode.started_at)} +
+ {formatDateTime(rootNode.started_at)}
)}
@@ -823,6 +869,12 @@ export function GraphPanel({ projectId, isVisible, onClose, focusTraceId }: Grap const [showToolsInGraph, setShowToolsInGraph] = useState(true); // Mobile: which column/tab is shown (runs | nodes | detail) const [mobileGraphTab, setMobileGraphTab] = useState<'runs' | 'nodes' | 'detail'>('runs'); + // Drilling runs -> nodes -> detail is navigation, so Back should walk it back + // rather than exiting the panel. Opening a trace detail and pressing Back used + // to leave the workspace entirely, which loses the whole path you took to get + // there. + useHistoryState('twGraphTab', mobileGraphTab, (tab) => + setMobileGraphTab(tab as 'runs' | 'nodes' | 'detail')); // Resizable column widths (pixels) const [col1Width, setCol1Width] = useState(208); // graph list @@ -987,8 +1039,13 @@ export function GraphPanel({ projectId, isVisible, onClose, focusTraceId }: Grap
{/* Column 1: Graph list */}
@@ -1037,8 +1094,8 @@ export function GraphPanel({ projectId, isVisible, onClose, focusTraceId }: Grap {/* Column 2: Node tree */} {selectedGraph && (
{selectedGraph.node_count} + {/* When this ran. The header named the trace and its + source but never said when — so the one question you + open an old trace to answer was the one thing the + detail view could not tell you. */} + {(() => { + const root = selectedGraph.nodes.find( + (n) => !n.parent_id || + !selectedGraph.nodes.find((p) => p.span_id === n.parent_id)); + return root ? ( + + {formatDateTime(root.started_at)} + + ) : null; + })()}
{selectedGraph.source && (
@@ -1138,8 +1212,8 @@ export function GraphPanel({ projectId, isVisible, onClose, focusTraceId }: Grap handleCol2Drag(-d)} darkMode={darkMode} />
{selectedNode ? ( +
diff --git a/frontend/src/components/panels/LibrarySpaceView.tsx b/frontend/src/components/panels/LibrarySpaceView.tsx index 29287ef..7a14a37 100644 --- a/frontend/src/components/panels/LibrarySpaceView.tsx +++ b/frontend/src/components/panels/LibrarySpaceView.tsx @@ -194,7 +194,7 @@ export function LibrarySpaceView({ project, dark, onClose, embedded }: Props) { }; return ( -
+
{/* Cover banner — hidden when embedded in SpacePage */} {!embedded &&
{coverSrc ? ( diff --git a/frontend/src/components/panels/LiveSessionsPanel.tsx b/frontend/src/components/panels/LiveSessionsPanel.tsx index becc7a1..824f5eb 100644 --- a/frontend/src/components/panels/LiveSessionsPanel.tsx +++ b/frontend/src/components/panels/LiveSessionsPanel.tsx @@ -163,13 +163,13 @@ export function LiveSessionsPanel({ agents: rawAgents, isVisible, onClose, fullP if (!isVisible) return null; const containerClass = fullPage - ? 'flex-1 flex overflow-hidden' - : 'flex overflow-hidden h-full'; + ? 'flex-1 flex flex-col md:flex-row min-h-0 overflow-hidden' + : 'flex flex-col md:flex-row overflow-hidden h-full min-h-0'; return (
{/* Agent list sidebar */} -
{/* Sidebar header */} diff --git a/frontend/src/components/panels/MemoryPanel.tsx b/frontend/src/components/panels/MemoryPanel.tsx index be1b202..40c98e4 100644 --- a/frontend/src/components/panels/MemoryPanel.tsx +++ b/frontend/src/components/panels/MemoryPanel.tsx @@ -487,9 +487,9 @@ function GraphTab({ userId, darkMode }: { userId: string; darkMode: boolean }) { : []; return ( -
+
{/* Entity list */} -
+
@@ -642,7 +642,7 @@ function StatsTab({ userId, darkMode }: { userId: string; darkMode: boolean }) { {'info' in c && (
-