Compare commits

...

6 Commits

Author SHA1 Message Date
dbczumar 0b12a9eb18 test(e2e-ui): isolate mobile chat mock response 2026-06-26 12:38:20 -07:00
dbczumar b55dcacddc test(e2e-ui): isolate terminal session mock llm state 2026-06-26 12:11:37 -07:00
dbczumar 23da77b3e8 test(e2e-ui): reset mock llm after mobile shells test 2026-06-25 17:36:19 -07:00
dbczumar 12eeea7011 fix(ap-web): close shells drawer when opening logs 2026-06-25 16:54:11 -07:00
dbczumar b803c24605 test(e2e-ui): cover mobile shells drawer 2026-06-25 16:51:59 -07:00
dbczumar 7970acc0d5 fix(ap-web): show shells entry on mobile 2026-06-25 16:45:17 -07:00
6 changed files with 153 additions and 36 deletions
+38
View File
@@ -2437,6 +2437,44 @@ describe("Mobile session menu", () => {
expect(screen.getByRole("menuitem", { name: /Shells/i })).toBeInTheDocument();
});
it("shows the Shells entry on mobile when the agent declares shell access", () => {
// No user shells exist yet (only the embedded REPL, excluded from the
// Shells inventory), but the agent declares a terminals: block. Mobile
// must mirror the desktop rail so the empty-state "+ New shell" entry
// point is reachable before the first shell exists.
useEnvironmentMock.mockReturnValue({
data: { available: true, root: null },
isLoading: false,
} as unknown as ReturnType<typeof useWorkspaceEnvironment>);
mockConversations([
{
id: "conv_sdk",
permission_level: null,
labels: { "omnigent.ui": "terminal" },
},
]);
useTerminalsMock.mockReturnValue({
terminals: [{ id: "terminal_tui_main", name: "tui", session: "main", running: true }],
isLoading: false,
error: null,
});
useSessionAgentMock.mockReturnValue({
data: { id: "ag_x", name: "polly", terminals: ["zsh"] },
} as ReturnType<typeof useSessionAgent>);
renderShell("/c/conv_sdk");
openSessionMenu();
const shellsEntry = screen.getByRole("menuitem", { name: /^Shells$/i });
expect(shellsEntry).toBeInTheDocument();
expect(shellsEntry).not.toHaveTextContent(/0/);
fireEvent.click(shellsEntry);
const drawer = screen.getByTestId("shells-panel-drawer");
expect(drawer).toHaveAttribute("data-state", "open");
expect(within(drawer).getByTestId("inline-terminals-section")).toBeInTheDocument();
});
it("opens the Agents drawer and mounts the subagents panel", () => {
useEnvironmentMock.mockReturnValue({
data: { available: true, root: null },
+38 -10
View File
@@ -66,6 +66,7 @@ import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
import { Toaster } from "@/components/ui/toast";
import { ForkSessionDialog } from "./ForkSessionDialog";
import { ForkDialogContextProvider, type ForkDialogContextValue } from "./ForkDialogContext";
import { InlineTerminalsSection } from "./InlineTerminalsSection";
import { WorkspacePanel } from "./WorkspacePanel";
import type { RightRailTab } from "./railTabs";
@@ -205,6 +206,7 @@ export function AppShell() {
// push panel of their own. On desktop these are tabs in the workspace rail;
// on a phone they open as full-screen overlays from the session-menu FAB.
const [subagentsPanelOpen, setSubagentsPanelOpen] = useState(false);
const [shellsPanelOpen, setShellsPanelOpen] = useState(false);
const [todosPanelOpen, setTodosPanelOpen] = useState(false);
// The right "Workspace" rail (WorkspacePanel) is open by default and
// remembers its open/closed state per session — a brand-new session starts
@@ -514,6 +516,7 @@ export function AppShell() {
setExecutionLogsKey(null);
setFilesPanelOpen(false);
setSubagentsPanelOpen(false);
setShellsPanelOpen(false);
setTodosPanelOpen(false);
setFilesPanelShowHidden(false);
if (!conversationId) {
@@ -844,6 +847,7 @@ export function AppShell() {
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setPanelInitialKey(key);
}
@@ -854,6 +858,7 @@ export function AppShell() {
setPanelInitialKey(null); // close terminals panel
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setExecutionLogsKey(key);
}
@@ -867,6 +872,7 @@ export function AppShell() {
setPanelInitialKey(null); // close terminals panel
setExecutionLogsKey(null); // close execution-logs panel
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setFilesPanelOpen(true);
}
@@ -879,10 +885,25 @@ export function AppShell() {
setPanelInitialKey(null); // close terminals panel
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setSubagentsPanelOpen(true);
}
// Mobile FAB → "Shells" opens the desktop rail's Shells tab content as a
// full-screen drawer. This preserves the "+ New shell" empty state on
// phones instead of requiring an existing shell before the entry works.
function openShellsPanel() {
setSelectedFilePath(null); // close file viewer
clearFileViewerUrl();
setPanelInitialKey(null); // close terminals panel / terminal-first view
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setTodosPanelOpen(false); // close mobile tasks drawer
setShellsPanelOpen(true);
}
// Mobile FAB → "Tasks" opens the todo list (the desktop rail's Tasks tab)
// as a full-screen drawer.
function openTodosPanel() {
@@ -892,18 +913,10 @@ export function AppShell() {
setExecutionLogsKey(null); // close execution-logs panel
setFilesPanelOpen(false); // close files drawer
setSubagentsPanelOpen(false); // close mobile agents drawer
setShellsPanelOpen(false); // close mobile shells drawer
setTodosPanelOpen(true);
}
function openFirstTerminal() {
// Mobile FAB → "Terminals" routes to the first terminal so the
// single tap is equivalent to clicking the first row in the
// desktop rail's terminals card. Inventory view — the embedded
// REPL terminal is the pill's Terminal view, not a rail entry.
if (railTerminals.length === 0) return;
openTerminalsPanel(terminalTabKey(railTerminals[0]));
}
function openMainExecutionLog() {
// Mobile FAB → "Execution logs" jumps straight to the main thread.
// Children are reachable via the panel's tab switcher.
@@ -1087,8 +1100,10 @@ export function AppShell() {
executionLogsOpen,
filesPanelOpen,
subagentsPanelOpen,
shellsPanelOpen,
todosPanelOpen,
hideTerminalsTab,
showShellsTab: railTabsAvailable.terminals,
terminalsLength: railTerminals.length,
isClaudeNative,
todosCompleted,
@@ -1098,7 +1113,7 @@ export function AppShell() {
subagentsWorking,
agentCount,
onOpenFiles: openFilesPanel,
onOpenFirstTerminal: openFirstTerminal,
onOpenShells: openShellsPanel,
onOpenSubagents: openSubagentsPanel,
onOpenTodos: openTodosPanel,
onOpenMainExecutionLog: openMainExecutionLog,
@@ -1211,6 +1226,19 @@ export function AppShell() {
<SubagentsPanel conversationId={conversationId} rootSessionId={rootSessionId} />
</MobilePanelDrawer>
)}
{conversationId && (
<MobilePanelDrawer
open={shellsPanelOpen}
title="Shells"
onClose={() => setShellsPanelOpen(false)}
testId="shells-panel-drawer"
>
<InlineTerminalsSection
conversationId={conversationId}
onExpand={openTerminalsPanel}
/>
</MobilePanelDrawer>
)}
{conversationId && (
<MobilePanelDrawer
open={todosPanelOpen}
+3 -1
View File
@@ -15,8 +15,10 @@ const mobileMenu = {
executionLogsOpen: false,
filesPanelOpen: false,
subagentsPanelOpen: false,
shellsPanelOpen: false,
todosPanelOpen: false,
hideTerminalsTab: false,
showShellsTab: false,
terminalsLength: 0,
isClaudeNative: false,
todosCompleted: 0,
@@ -26,7 +28,7 @@ const mobileMenu = {
subagentsWorking: 0,
agentCount: 1,
onOpenFiles: () => {},
onOpenFirstTerminal: () => {},
onOpenShells: () => {},
onOpenSubagents: () => {},
onOpenTodos: () => {},
onOpenMainExecutionLog: () => {},
+21 -14
View File
@@ -46,11 +46,15 @@ interface MobileSessionMenuProps {
filesPanelOpen: boolean;
/** True while the mobile agents drawer is open. */
subagentsPanelOpen: boolean;
/** True while the mobile shells drawer is open. */
shellsPanelOpen: boolean;
/** True while the mobile tasks drawer is open. */
todosPanelOpen: boolean;
/** Hide the Shells entry (claude-native sub-agents only). */
hideTerminalsTab: boolean;
/** Number of open terminals (entry badge + visibility). */
/** Whether the Shells entry is available. */
showShellsTab: boolean;
/** Number of open terminals (entry badge). */
terminalsLength: number;
/** Whether this is a claude-native session (gates the Tasks entry). */
isClaudeNative: boolean;
@@ -71,8 +75,8 @@ interface MobileSessionMenuProps {
agentCount: number;
/** Open the mobile files drawer. */
onOpenFiles: () => void;
/** Open the first terminal in the terminals push panel. */
onOpenFirstTerminal: () => void;
/** Open the mobile shells drawer. */
onOpenShells: () => void;
/** Open the mobile agents drawer. */
onOpenSubagents: () => void;
/** Open the mobile tasks drawer. */
@@ -357,6 +361,7 @@ export function ChatHeader({
!mobileMenu.executionLogsOpen &&
!mobileMenu.filesPanelOpen &&
!mobileMenu.subagentsPanelOpen &&
!mobileMenu.shellsPanelOpen &&
!mobileMenu.todosPanelOpen &&
(hasRailContent || mobileMenu.debugMode) && (
<DropdownMenu>
@@ -411,22 +416,24 @@ export function ChatHeader({
: mobileMenu.agentCount}
</span>
</DropdownMenuItem>
{/* Shells — hidden only for claude-native sub-agents,
matching the desktop rail's Shells tab. The agent's
own terminal (SDK REPL / native vendor pane) is
excluded from the count (it's reached via the
Chat/Terminal pill), so this entry appears only
when real shells exist. */}
{!mobileMenu.hideTerminalsTab && mobileMenu.terminalsLength > 0 && (
{/* Shells — mirrors the desktop rail's Shells tab: visible
when a real shell exists, or when the agent spec declares
shell access so the empty-state "+ New shell" affordance
is reachable on mobile too. */}
{!mobileMenu.hideTerminalsTab && mobileMenu.showShellsTab && (
<DropdownMenuItem
onSelect={mobileMenu.onOpenFirstTerminal}
onSelect={mobileMenu.onOpenShells}
className="gap-2.5 px-2.5 py-2 text-base"
>
<TerminalIcon className="size-4" />
Shells
<span className={cn(TAB_BADGE_BASE, "ml-auto bg-muted text-muted-foreground")}>
{mobileMenu.terminalsLength}
</span>
{mobileMenu.terminalsLength > 0 && (
<span
className={cn(TAB_BADGE_BASE, "ml-auto bg-muted text-muted-foreground")}
>
{mobileMenu.terminalsLength}
</span>
)}
</DropdownMenuItem>
)}
{mobileMenu.isClaudeNative && mobileMenu.todosTotal > 0 && (
+14 -9
View File
@@ -1386,16 +1386,21 @@ def terminal_session(
try:
yield (live_server, session_id)
finally:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
# Restore the "found" state: if we respawned the runner (a prior
# test had killed it), tear our copy down so it doesn't outlive us.
if respawned_runner is not None:
respawned_runner.terminate()
try:
httpx.delete(f"{live_server}/v1/sessions/{session_id}", timeout=10.0)
finally:
try:
respawned_runner.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned_runner.kill()
respawned_runner.wait(timeout=5)
reset_mock_llm(mock_llm_server_url)
finally:
# Restore the "found" state: if we respawned the runner (a prior
# test had killed it), tear our copy down so it doesn't outlive us.
if respawned_runner is not None:
respawned_runner.terminate()
try:
respawned_runner.wait(timeout=5)
except subprocess.TimeoutExpired:
respawned_runner.kill()
respawned_runner.wait(timeout=5)
_TWO_AGENT_PARENT_NAME = "hitchhikers_chat"
+39 -2
View File
@@ -29,6 +29,8 @@ import httpx
import pytest
from playwright.sync_api import Page, ViewportSize, expect
from tests.e2e_ui.conftest import configure_mock_llm
# iPhone-12-class portrait viewport — comfortably below the Tailwind
# ``md`` breakpoint (768px) so every ``md:`` rule resolves to its
# mobile branch.
@@ -180,6 +182,33 @@ def test_mobile_fab_lists_file_surfaces_and_omits_absent_ones(
expect(page.get_by_role("menuitem", name="Tasks")).to_have_count(0)
def test_mobile_shells_drawer_exposes_new_shell_before_shells_exist(
page: Page,
terminal_session: tuple[str, str],
) -> None:
"""Shell-capable agents expose the mobile Shells drawer at zero shells.
The desktop rail shows Shells by default when the session agent declares a
``terminals:`` block, because the empty state is the "+ New shell" entry
point. Mobile must mirror that behavior: the FAB should list Shells before
any user shell has been created, and selecting it should open the full-screen
Shells drawer containing the same "+ New shell" row.
"""
base_url, session_id = terminal_session
page.set_viewport_size(_MOBILE_VIEWPORT)
page.goto(f"{base_url}/c/{session_id}")
page.get_by_role("button", name="Open session menu").click()
shells_entry = page.get_by_role("menuitem", name="Shells", exact=True)
expect(shells_entry).to_be_visible(timeout=10_000)
shells_entry.click()
drawer = page.get_by_test_id("shells-panel-drawer")
expect(drawer).to_have_attribute("data-state", "open")
expect(drawer.get_by_role("button", name="New shell")).to_be_visible()
def test_mobile_fab_shows_agents_entry_when_child_agents_exist(
page: Page,
mobile_session_with_child_agent: tuple[str, str],
@@ -260,6 +289,7 @@ def test_mobile_files_drawer_opens_seeded_file(
def test_mobile_chat_send_and_response(
page: Page,
seeded_session: tuple[str, str],
mock_llm_server_url: str,
) -> None:
"""The composer streams an assistant reply at a phone viewport.
@@ -267,14 +297,21 @@ def test_mobile_chat_send_and_response(
and message list stay usable on a phone (no rail stealing layout).
"""
base_url, session_id = seeded_session
prompt = "Say 'mobile-pong-e2e' in one word."
configure_mock_llm(
mock_llm_server_url,
[{"text": "mobile-pong-e2e"}],
key="mobile-chat-send-and-response",
match=prompt,
)
page.set_viewport_size(_MOBILE_VIEWPORT)
page.goto(f"{base_url}/c/{session_id}")
composer = page.get_by_placeholder("Ask the agent anything…")
expect(composer).to_be_visible()
composer.fill("Say 'pong' in one word.")
composer.fill(prompt)
page.get_by_role("button", name="Send", exact=True).click()
assistant = page.locator('[data-testid="message-bubble"][data-role="assistant"]').first
expect(assistant).to_be_visible(timeout=60_000)
expect(assistant).to_have_text(re.compile(r"\S"), timeout=60_000)
expect(assistant).to_contain_text("mobile-pong-e2e", timeout=60_000)