Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit eed1626

Browse files
committed
🤖 feat: refine mobile navigation and bottom composer
Bring phone workspace search and creation into a fixed bottom dock, show project/server context in left-aligned conversation headers, and group navigation actions. Keep the input bottommost with Plan/model controls above it. Expand text entry for focus/drafts while preserving web picker clicks through browser focus retention rather than timers or moving controls below the input. Draw focus on the rounded composer boundary. Validate phone/wide/short layouts, keyboard and pointer picker activation, draft/settings retention, send/interrupt controls, and native bundling. --- _Generated with [`mux`](https://github.com/coder/mux) • Model: `coder:openai/gpt-6-astra` • Thinking: `high` • Cost: `$428.45`_ <!-- mux-attribution: model=coder:openai/gpt-6-astra thinking=high costs=428.45 -->
1 parent 41febc6 commit eed1626

7 files changed

Lines changed: 391 additions & 208 deletions

File tree

‎docs/integrations/mobile-app.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Develop the React Native Xum companion and connect it to your serve
55

66
The experimental mobile companion lives in `packages/mobile`. It uses native React Native views, with React Native Web for browser development—not an embedded copy of the desktop website.
77

8-
It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Drafts and unsent model choices survive returning to the list. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.
8+
It connects to your existing Xum server for projects, workspace creation, conversations, agent/model selection, and read-only changes. On phones, a searchable workspace list opens conversations in a native navigation stack; wider screens keep the workspace sidebar visible. Phone workspace search and creation stay in a bottom dock, while wide layouts keep their controls above the list. Conversation headers show project and server context with grouped navigation actions. Drafts and unsent model choices survive returning to the list. The composer stays compact when empty and unfocused, expands for writing, and stays at the bottom with mode/model controls directly above it. Creation uses a sheet with a pinned action. The composer's separate model and mode controls open focused pickers; selections apply immediately, and changing mode preserves the chosen model and effort. Search models directly in the model picker by name, provider, or alias. The grouped list follows model visibility in Settings and includes models available through configured gateway routes; the current selection remains visible even if subsequently hidden. Use Effort to adjust thinking. Custom model IDs require confirmation. Tool activity stays compact in the conversation; tap a tool to inspect its input, output, and status. Provider configuration, terminal/desktop access, and advanced administration remain in the main Xum app.
99

1010
## Connect to a server
1111

‎packages/mobile/App.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { SettingsScreen } from "./src/screens/SettingsScreen";
1717
import { Button, Header, Loading, Notice } from "./src/components/Controls";
1818
import { useProjects } from "./src/useProjects";
1919
import { useConnection } from "./src/useConnection";
20-
import { colors, layout } from "./src/theme";
20+
import { colors, layout, WIDE_LAYOUT_MIN_WIDTH } from "./src/theme";
2121
import type { ChatSettings } from "./src/settings";
2222

2323
export type MobileRoutes = {
@@ -201,7 +201,7 @@ function ScreenLayout(props: {
201201
const { width } = useWindowDimensions();
202202
return (
203203
<SafeAreaView style={[layout.fill, { flexDirection: "row" }]}>
204-
{width >= 900 && (
204+
{width >= WIDE_LAYOUT_MIN_WIDTH && (
205205
<View style={{ width: 300, borderRightWidth: 1, borderRightColor: colors.border }}>
206206
<WorkspaceList
207207
compact
@@ -228,12 +228,14 @@ function ConversationRoute(props: NativeStackScreenProps<MobileRoutes, "Conversa
228228
<ConversationScreen
229229
key={workspaceId}
230230
client={session.connection.client}
231+
serverLabel={new URL(session.connection.endpoint).host}
231232
workspace={workspace}
232233
signal={session.signal}
233234
connected={session.ready}
234235
onReconnect={session.reconnect}
235236
onBack={() => props.navigation.popTo("Workspaces")}
236237
onChanges={() => props.navigation.navigate("Changes", { workspaceId })}
238+
onSettings={() => props.navigation.navigate("Settings")}
237239
selection={selections[workspaceId] ?? null}
238240
onSelectionChange={(value) => setSelection(workspaceId, value)}
239241
draft={drafts[workspaceId] ?? ""}

‎packages/mobile/e2e/native-ux.spec.ts‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,31 @@ test("native stack preserves drafts and sheets keep their actions reachable", as
2626
await expect(page.getByRole("textbox", { name: "Bearer token", exact: true })).toBeFocused();
2727
await page.getByRole("textbox", { name: "Bearer token", exact: true }).fill(token);
2828
await page.getByRole("button", { name: /^Connect(?: without encryption)?$/ }).click();
29-
await expect(page.getByRole("textbox", { name: "Search workspaces" })).toBeVisible();
29+
const workspaceSearch = page.getByRole("textbox", { name: "Search workspaces" });
30+
const newWorkspace = page.getByRole("button", { name: "New workspace", exact: true }).first();
31+
await expect(workspaceSearch).toBeVisible();
32+
const viewport = page.viewportSize()!;
33+
await expect
34+
.poll(async () => {
35+
const y = (await workspaceSearch.boundingBox())!.y;
36+
return viewport.width < 900 ? y > viewport.height - 130 : y < 180;
37+
})
38+
.toBe(true);
39+
await withinViewport(page, newWorkspace);
40+
const searchY = (await workspaceSearch.boundingBox())!.y;
41+
await page.getByTestId("workspace-list").evaluate((list) => {
42+
list.scrollTop = list.scrollHeight;
43+
});
44+
await expect.poll(async () => (await workspaceSearch.boundingBox())!.y).toBe(searchY);
45+
await workspaceSearch.fill("keep this query while resizing");
46+
await page.setViewportSize({
47+
width: viewport.width < 900 ? 1200 : 375,
48+
height: viewport.height,
49+
});
50+
await expect(workspaceSearch).toHaveValue("keep this query while resizing");
51+
await expect(workspaceSearch).toBeFocused();
52+
await page.setViewportSize(viewport);
53+
await workspaceSearch.fill("");
3054

3155
await page.getByRole("button", { name: "New workspace", exact: true }).first().click();
3256
const create = page.getByRole("button", { name: "Create scratch chat", exact: true });
@@ -36,9 +60,52 @@ test("native stack preserves drafts and sheets keep their actions reachable", as
3660
await expect(page.getByRole("textbox", { name: "Message", exact: true })).toBeVisible();
3761
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
3862
const message = page.getByRole("textbox", { name: "Message", exact: true });
63+
await expect(
64+
page.getByText(`Scratch chat · ${new URL(endpoint).host}`, { exact: true })
65+
).toBeVisible();
66+
await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(50);
67+
const modeControl = page.getByRole("button", { name: "Choose mode", exact: true });
68+
const modelControl = page.getByRole("button", { name: "Choose model", exact: true });
69+
for (const control of [modeControl, modelControl]) {
70+
await expect
71+
.poll(async () => {
72+
const button = (await control.boundingBox())!;
73+
const input = (await message.boundingBox())!;
74+
return button.y + button.height <= input.y;
75+
})
76+
.toBe(true);
77+
}
78+
await message.focus();
79+
await expect(message).toBeFocused();
80+
await expect.poll(async () => (await message.boundingBox())!.height).toBeGreaterThan(60);
81+
// Pointer-down blurs the input before click: the toolbar must not shift below that press.
82+
const modelBounds = (await modelControl.boundingBox())!;
83+
await page.mouse.move(
84+
modelBounds.x + modelBounds.width / 2,
85+
modelBounds.y + modelBounds.height / 2
86+
);
87+
await page.mouse.down();
88+
await expect.poll(async () => (await message.boundingBox())!.height).toBeGreaterThan(60);
89+
await expect.poll(async () => (await modelControl.boundingBox())!.y).toBe(modelBounds.y);
90+
await expect(page.getByRole("textbox", { name: "Search models" })).toHaveCount(0);
91+
await page.mouse.up();
92+
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
93+
await page.getByRole("button", { name: "Close", exact: true }).click();
94+
await message.focus();
95+
await page.getByRole("button", { name: "Choose mode", exact: true }).click();
96+
await expect(page.getByRole("radio", { name: /^Plan/ })).toBeVisible();
97+
await page.getByRole("button", { name: "Close", exact: true }).click();
98+
await modelControl.focus();
99+
await page.keyboard.press("Enter");
100+
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
101+
await page.getByRole("button", { name: "Close", exact: true }).click();
102+
await message.focus();
39103
await message.fill("A line of a longer draft\n".repeat(10));
40104
await expect.poll(async () => (await message.boundingBox())!.height).toBeGreaterThan(80);
41105
await message.fill("");
106+
await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(80);
107+
await page.getByRole("button", { name: "Connection settings", exact: true }).click();
108+
await page.getByRole("button", { name: "Back", exact: true }).click();
42109
await expect.poll(async () => (await message.boundingBox())!.height).toBeLessThanOrEqual(50);
43110
const draft = "Keep this unsent draft while navigating.";
44111
await message.fill(draft);
@@ -81,7 +148,7 @@ test("native stack preserves drafts and sheets keep their actions reachable", as
81148
await page.getByRole("button", { name: "Close", exact: true }).click();
82149

83150
await message.fill("");
84-
const viewport = page.viewportSize()!;
151+
await message.blur();
85152
await page.setViewportSize({
86153
width: viewport.width === 1200 ? 375 : 1200,
87154
height: viewport.height,
@@ -92,6 +159,15 @@ test("native stack preserves drafts and sheets keep their actions reachable", as
92159
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(
93160
true
94161
);
162+
// Exercise the relocated send/stop targets through the real disposable server.
163+
await message.fill("[mock:tool:parallel-step]");
164+
await send.click();
165+
const interrupt = page.getByRole("button", { name: "Interrupt agent", exact: true });
166+
await expect(interrupt).toBeVisible();
167+
await withinViewport(page, interrupt);
168+
await interrupt.click();
169+
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
170+
await expect(message).toHaveValue("");
95171
expect(
96172
await page.evaluate(
97173
(secret) =>

0 commit comments

Comments
 (0)