Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b12a9eb18 | |||
| b55dcacddc | |||
| 23da77b3e8 | |||
| 12eeea7011 | |||
| b803c24605 | |||
| 7970acc0d5 |
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user