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
68 changes: 68 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Phone-Friendly Remote Control UX Implementation Plan

## Context & Pain Points

When a user is running OpenCode on their desktop but monitoring it from their phone, the current UX for handling input prompts (questions, permissions) has several friction points:

1. **Visibility**: The user might not realize the session is blocked waiting for input if they are looking at the "changes" tab or if the prompt is scrolled out of view.
2. **Touch Targets**: While some buttons use `size="large"`, the custom input textareas and option checkboxes can be hard to tap accurately on mobile.
3. **Keyboard Obscuration**: When typing a custom answer on mobile, the virtual keyboard often obscures the prompt context or the submit button.
4. **Context Switching**: Switching between the "session" tab (to answer) and "changes" tab (to review what the agent did before asking) is cumbersome.

## Proposed First Slice (Minimal & Incremental)

Focus on **Visibility** and **Touch Ergonomics** for the existing `DockPrompt` components (`SessionQuestionDock` and `SessionPermissionDock`).

### 1. Sticky/Prominent "Blocked" Indicator

When the session is blocked waiting for input, ensure this state is immediately obvious regardless of scroll position or active tab.

- **Implementation**: Add a sticky banner or floating action button (FAB) at the bottom of the screen (above the composer) on mobile when a prompt is active. Tapping it scrolls to the prompt or switches to the "session" tab if needed.
- **Touched Files**:
- `packages/app/src/pages/session.tsx` (to add the global indicator based on `composer.blocked()`)
- `packages/app/src/pages/session/composer/session-composer-region.tsx` (to position it relative to the composer)

### 2. Improved Touch Targets for Options

Make the entire option row in `SessionQuestionDock` a larger, more forgiving touch target.

- **Implementation**: Increase padding on `[data-slot="question-option"]` in mobile views. Ensure the custom input textarea expands properly and doesn't require precise tapping to focus.
- **Touched Files**:
- `packages/ui/src/components/message-part.css` (where `[data-slot="question-option"]` is styled)
- `packages/app/src/pages/session/composer/session-question-dock.tsx`

### 3. Auto-Scroll to Prompt on Mobile

When a new prompt appears, automatically scroll it into view, especially on mobile where screen real estate is limited.

- **Implementation**: Enhance the `measure` or `onMount` logic in `SessionQuestionDock` and `SessionPermissionDock` to trigger a scroll-into-view if the component is rendered and the viewport is mobile-sized.
- **Touched Files**:
- `packages/app/src/pages/session/composer/session-question-dock.tsx`
- `packages/app/src/pages/session/composer/session-permission-dock.tsx`

## Test Approach

1. **Unit/Component Tests**:
- Verify the "Blocked" indicator renders when `composer.blocked()` is true.
- Verify click handlers on the indicator correctly update the active tab and scroll position.
2. **E2E Tests (Playwright)**:
- Create a test simulating a mobile viewport (`isMobile: true` in Playwright config).
- Trigger a permission prompt.
- Verify the sticky indicator appears.
- Click the indicator and verify the prompt is visible.
- Interact with the larger touch targets.

## Browser-Validation Steps (Manual)

1. Start the backend (`bun run --conditions=browser ./src/index.ts serve --port 4096`) and frontend (`bun dev -- --port 4444`).
2. Open `http://localhost:4444` in a desktop browser.
3. Use Chrome DevTools Device Toolbar (F12 -> Ctrl+Shift+M) to simulate a mobile device (e.g., iPhone 14 Pro).
4. Start a session and trigger a command that requires permission (e.g., `bash ls`).
5. **Verify**:
- The new sticky "Blocked" indicator appears.
- Tapping it scrolls the permission dock into view.
- The "Allow" / "Deny" buttons are easily tappable.
6. Trigger a question prompt (e.g., using a test script or specific agent interaction).
7. **Verify**:
- The options have adequate padding for touch.
- Selecting a custom input option focuses the textarea without the virtual keyboard hiding the context (simulate keyboard by resizing viewport height).
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,56 @@ Learn more about [agents](https://opencode.ai/docs/agents).

For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).

### Automation and remote control

OpenCode can now be used for lightweight automation and remote human-in-the-loop workflows.

#### Triggers

List triggers:

```bash
opencode trigger list
```

Create a repeating command trigger:

```bash
opencode trigger create --interval 60000 --session ses_123 --command summarize --arguments "--daily"
```

Create a one-shot webhook trigger:

```bash
opencode trigger create --at 1743600000000 --webhook https://example.com/hook --method POST --body '{"ok":true}'
```

Fire, enable, disable, or delete a trigger:

```bash
opencode trigger fire <id>
opencode trigger enable <id>
opencode trigger disable <id>
opencode trigger delete <id>
```

#### Remote control from another device

Run OpenCode on a machine that stays on:

```bash
export OPENCODE_SERVER_PASSWORD='choose-a-strong-password'
opencode web --hostname 0.0.0.0 --port 4096
```

From another computer, attach to it directly:

```bash
opencode attach http://your-host:4096 --dir /path/to/project --workspace ws_123 --continue
```

From a phone, open the web UI in a browser. The app now surfaces blocked sessions more clearly with an awaiting-input inbox, mobile session attention states, and browser title/app-badge attention when OpenCode needs you.

### Contributing

If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request.
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/components/settings-general.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { notificationPermissionCopy } from "./settings-general.helpers"

describe("notificationPermissionCopy", () => {
test("offers an enable action when permission is undecided", () => {
expect(notificationPermissionCopy("default")).toEqual({
title: "Browser notifications",
description: "Allow notifications so your phone or browser can alert you when OpenCode needs input.",
action: "Enable",
})
})

test("explains denied permissions without an action", () => {
expect(notificationPermissionCopy("denied")).toEqual({
title: "Browser notifications",
description: "Blocked in this browser. Re-enable notifications in your browser or site settings to get alerts.",
action: undefined,
})
})
})
33 changes: 33 additions & 0 deletions packages/app/src/components/settings-general.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { NotificationPermissionState } from "@/context/platform"

export const notificationPermissionCopy = (state: NotificationPermissionState) => {
if (state === "granted") {
return {
title: "Browser notifications",
description: "Enabled in this browser. You can get alerts when OpenCode needs your input.",
action: undefined,
}
}

if (state === "default") {
return {
title: "Browser notifications",
description: "Allow notifications so your phone or browser can alert you when OpenCode needs input.",
action: "Enable",
}
}

if (state === "denied") {
return {
title: "Browser notifications",
description: "Blocked in this browser. Re-enable notifications in your browser or site settings to get alerts.",
action: undefined,
}
}

return {
title: "Browser notifications",
description: "This browser does not support system notifications.",
action: undefined,
}
}
32 changes: 32 additions & 0 deletions packages/app/src/components/settings-general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { notificationPermissionCopy } from "./settings-general.helpers"
import { Link } from "./link"
import { SettingsList } from "./settings-list"

Expand Down Expand Up @@ -65,6 +66,10 @@ export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
const platform = usePlatform()
const [notify, { refetch: refetchNotify }] = createResource(async () => {
if (!platform.notificationPermission) return
return platform.notificationPermission()
})
const settings = useSettings()

onMount(() => {
Expand Down Expand Up @@ -410,6 +415,33 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>

<Show when={notify()} keyed>
{(state) => {
const item = () => notificationPermissionCopy(state)
return (
<SettingsRow title={item().title} description={item().description}>
<Show when={item().action && platform.requestNotificationPermission} fallback={<div />}>
<Button
size="small"
variant="secondary"
onClick={async () => {
const next = await platform.requestNotificationPermission?.()
await refetchNotify()
if (next === "granted") {
showToast({ title: "Notifications enabled" })
return
}
showToast({ title: "Notifications not enabled" })
}}
>
{item().action}
</Button>
</Show>
</SettingsRow>
)
}}
</Show>
</SettingsList>
</div>
)
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
export type NotificationPermissionState = "unsupported" | "default" | "denied" | "granted"

export type Platform = {
/** Platform discriminator */
Expand Down Expand Up @@ -36,6 +37,8 @@ export type Platform = {

/** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
notificationPermission?(): Promise<NotificationPermissionState>
requestNotificationPermission?(): Promise<NotificationPermissionState>

/** Open directory picker dialog (native on Tauri, server-backed on web) */
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
Expand Down
23 changes: 16 additions & 7 deletions packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform"
import { type NotificationPermissionState, type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
Expand Down Expand Up @@ -52,13 +52,20 @@ const setStorage = (key: string, value: string | null) => {
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)

const notify: Platform["notify"] = async (title, description, href) => {
if (!("Notification" in window)) return
const notificationPermission = async (): Promise<NotificationPermissionState> => {
if (!("Notification" in window)) return "unsupported"
return Notification.permission
}

const permission =
Notification.permission === "default"
? await Notification.requestPermission().catch(() => "denied")
: Notification.permission
const requestNotificationPermission = async (): Promise<NotificationPermissionState> => {
if (!("Notification" in window)) return "unsupported"
return Notification.permission === "default"
? await Notification.requestPermission().catch(() => "denied")
: Notification.permission
}

const notify: Platform["notify"] = async (title, description, href) => {
const permission = await requestNotificationPermission()

if (permission !== "granted") return

Expand Down Expand Up @@ -118,6 +125,8 @@ const platform: Platform = {
forward,
restart,
notify,
notificationPermission,
requestNotificationPermission,
getDefaultServer: async () => {
const stored = readDefaultServerUrl()
return stored ? ServerConnection.Key.make(stored) : null
Expand Down
Loading