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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/__tests__/mobile-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
2 changes: 1 addition & 1 deletion frontend/src/components/chat/ClaudeCodeStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export function ClaudeCodeStatus({ darkMode }: ClaudeCodeStatusProps) {

{/* Expanded dropdown */}
{expanded && (
<div className={`absolute top-full right-0 mt-1 z-50 w-72 rounded-lg shadow-lg border ${
<div className={`absolute top-full right-0 mt-1 z-50 w-[min(18rem,calc(100vw-2rem))] rounded-lg shadow-lg border ${
darkMode ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'
}`}>
<div className={`px-3 py-2 border-b text-xs font-semibold ${
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/chat/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function EmojiPicker({ onSelect, onClose, position = 'above' }: EmojiPick
<div
ref={ref}
className={clsx(
'absolute z-50 w-72 rounded-xl shadow-xl border overflow-hidden',
'absolute z-50 w-[min(18rem,calc(100vw-2rem))] rounded-xl shadow-xl border overflow-hidden',
position === 'above' ? 'bottom-full mb-2' : 'top-full mt-2',
darkMode ? 'bg-slate-800 border-slate-600' : 'bg-white border-gray-200'
)}
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/chat/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ export function MessageInput({
>
{/* Mention popup */}
{showMentions && filteredAgents.length > 0 && (
<div className={`absolute bottom-full left-0 w-64 mb-2 border rounded-lg shadow-lg overflow-hidden ${popupBg}`}>
<div className={`absolute bottom-full left-0 w-[min(16rem,calc(100vw-2rem))] mb-2 border rounded-lg shadow-lg overflow-hidden ${popupBg}`}>
<div className={`px-3 py-2 text-xs font-medium ${popupHeaderBg}`}>
Team Members
</div>
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,9 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick,
<span className={`text-xs ${timestampColor}`}>{time}</span>
</div>
<div className={`message-content ${messageTextColor}`}>
<MarkdownContent content={message.content} />
<div className="message-text min-w-0">
<MarkdownContent content={message.content} />
</div>
</div>
{attachments.length > 0 && <AttachmentGrid attachments={attachments} darkMode={darkMode} />}
<ReactionDisplay reactions={reactions} onToggle={handleReact} />
Expand All @@ -355,7 +357,9 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick,
</div>
<div className="flex-1 min-w-0">
<div className={`message-content ${messageTextColor}`}>
<MarkdownContent content={message.content} />
<div className="message-text min-w-0">
<MarkdownContent content={message.content} />
</div>
</div>
{attachments.length > 0 && <AttachmentGrid attachments={attachments} darkMode={darkMode} />}
<ReactionDisplay reactions={reactions} onToggle={handleReact} />
Expand All @@ -369,7 +373,7 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick,
{/* Quick reactions */}
<div className="relative">
<button
className={`p-1 rounded ${actionsHoverBg}`}
className={`p-2 md:p-1 min-h-[40px] min-w-[40px] md:min-h-0 md:min-w-0 flex items-center justify-center rounded ${actionsHoverBg}`}
onClick={() => setShowReactionPicker((v) => !v)}
title="Add reaction"
>
Expand Down Expand Up @@ -408,7 +412,7 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick,
{/* Trace button — shown only for agent messages with trace metadata */}
{message.extra_data?.trace_id ? (
<button
className={`p-1 rounded ${actionsHoverBg}`}
className={`p-2 md:p-1 min-h-[40px] min-w-[40px] md:min-h-0 md:min-w-0 flex items-center justify-center rounded ${actionsHoverBg}`}
onClick={() => {
const traceId = String(message.extra_data!.trace_id);
if (onTraceClick) {
Expand Down Expand Up @@ -449,7 +453,7 @@ function MessageItem({ message, agent, showHeader, onThreadClick, onAgentClick,
) : null}
{onThreadClick && (
<button
className={`p-1 rounded ${actionsHoverBg}`}
className={`p-2 md:p-1 min-h-[40px] min-w-[40px] md:min-h-0 md:min-w-0 flex items-center justify-center rounded ${actionsHoverBg}`}
onClick={() => onThreadClick(message.id)}
>
<MessageSquare className={`w-4 h-4 ${actionsIconColor}`} />
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/common/McpSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,20 @@ export function McpSection({ space, spaceName, darkMode = false }: Props) {
{grant.rotated ? 'New key issued — the previous one no longer works' : 'Key issued'}
</p>
<p className={clsx('text-xs mb-2', t2)}>{grant.warning}</p>
<p className={clsx('text-xs mt-3 mb-1 font-medium', t2)}>Claude Code</p>
<div className={pre}>{grant.connect}</div>
<div className="flex gap-2 mt-2 flex-wrap">
<CopyButton text={grant.connect} label="Copy connect command" darkMode={darkMode} />
<CopyButton text={grant.connect} label="Copy command" darkMode={darkMode} />
</div>

{/* Codex is configured by file rather than by a CLI call, and it
speaks stdio — so an HTTP server is reached through mcp-remote.
We named Codex in the blurb above and then handed over a
`claude mcp add` line, which only helps one of the two. */}
<p className={clsx('text-xs mt-3 mb-1 font-medium', t2)}>Codex</p>
<div className={pre}>{grant.connect_codex}</div>
<div className="flex gap-2 mt-2 flex-wrap">
<CopyButton text={grant.connect_codex} label="Copy config" darkMode={darkMode} />
<CopyButton text={grant.token} label="Copy token" darkMode={darkMode} />
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function ModelPicker({ info, darkMode = false, onSelect }: Props) {
darkMode ? 'text-gray-500' : 'text-gray-400');

return (
<div className={clsx('w-80 rounded-lg border shadow-lg overflow-hidden flex flex-col',
<div className={clsx('w-[min(20rem,calc(100vw-2rem))] rounded-lg border shadow-lg overflow-hidden flex flex-col',
darkMode ? 'bg-slate-800 border-slate-700' : 'bg-white border-gray-200')}>

{/* Auto and the tiers stay pinned: they are the choices most people want,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/panels/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex-1 flex flex-col overflow-hidden" style={{ display: isVisible ? 'flex' : 'none' }}>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden" style={{ display: isVisible ? 'flex' : 'none' }}>
{/* Browser toolbar — wraps on very narrow (mobile) widths so the trailing
actions (esp. the chat toggle) are never clipped off the right edge. */}
<div className={`flex flex-wrap items-center gap-1.5 px-2 py-1.5 border-b ${darkMode ? 'border-slate-700 bg-slate-800' : 'border-gray-200 bg-gray-50'}`}>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/panels/DesktopPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export function DesktopPanel({ projectId, isVisible, onClose }: Props) {
};

return (
<div ref={containerRef} tabIndex={-1} className="flex-1 flex flex-col overflow-hidden outline-none">
<div ref={containerRef} tabIndex={-1} className="flex-1 flex flex-col min-h-0 overflow-hidden outline-none">
{/* 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
Expand Down
Loading
Loading