diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 000000000000..e685a7445c23 --- /dev/null +++ b/PLAN.md @@ -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). diff --git a/README.md b/README.md index 79ccf8b34910..ee60c4cc367a 100644 --- a/README.md +++ b/README.md @@ -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 +opencode trigger enable +opencode trigger disable +opencode trigger delete +``` + +#### 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. diff --git a/packages/app/src/components/settings-general.helpers.test.ts b/packages/app/src/components/settings-general.helpers.test.ts new file mode 100644 index 000000000000..2e857d997121 --- /dev/null +++ b/packages/app/src/components/settings-general.helpers.test.ts @@ -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, + }) + }) +}) diff --git a/packages/app/src/components/settings-general.helpers.ts b/packages/app/src/components/settings-general.helpers.ts new file mode 100644 index 000000000000..0e37b7dc205f --- /dev/null +++ b/packages/app/src/components/settings-general.helpers.ts @@ -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, + } +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index ec0614729c92..fc6b452ee608 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -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" @@ -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(() => { @@ -410,6 +415,33 @@ export const SettingsGeneral: Component = () => { /> + + + {(state) => { + const item = () => notificationPermissionCopy(state) + return ( + + }> + + + + ) + }} + ) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 3bdc46391b67..9f16faa95d3b 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -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 */ @@ -36,6 +37,8 @@ export type Platform = { /** Send a system notification (optional deep link) */ notify(title: string, description?: string, href?: string): Promise + notificationPermission?(): Promise + requestNotificationPermission?(): Promise /** Open directory picker dialog (native on Tauri, server-backed on web) */ openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75d3..9cf661cbac43 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -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" @@ -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 => { + 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 => { + 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 @@ -118,6 +125,8 @@ const platform: Platform = { forward, restart, notify, + notificationPermission, + requestNotificationPermission, getDefaultServer: async () => { const stored = readDefaultServerUrl() return stored ? ServerConnection.Key.make(stored) : null diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b5a96110f651..95cca2a8ed54 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -64,6 +64,9 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { + attentionTitle, + awaitingSessions, + childMapByParent, displayName, effectiveWorkspaceOrder, errorMessage, @@ -86,6 +89,7 @@ import { } from "./layout/sidebar-workspace" import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" +import { SessionItem } from "./layout/sidebar-items" export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( @@ -429,14 +433,32 @@ export default function Layout(props: ParentProps) { onMount(() => { const toastBySession = new Map() const alertedAtBySession = new Map() + const attention = new Set() const cooldownMs = 5000 + const baseTitle = document.title + + const syncAttention = () => { + const count = attention.size + document.title = attentionTitle(baseTitle, count) + const nav = navigator as Navigator & { + setAppBadge?: (count?: number) => Promise + clearAppBadge?: () => Promise + } + if (count > 0) { + void nav.setAppBadge?.(count).catch(() => undefined) + return + } + void nav.clearAppBadge?.().catch(() => undefined) + } const dismissSessionAlert = (sessionKey: string) => { const toastId = toastBySession.get(sessionKey) - if (toastId === undefined) return - toaster.dismiss(toastId) - toastBySession.delete(sessionKey) + if (toastId !== undefined) { + toaster.dismiss(toastId) + toastBySession.delete(sessionKey) + } alertedAtBySession.delete(sessionKey) + if (attention.delete(sessionKey)) syncAttention() } const unsub = globalSDK.event.listen((e) => { @@ -510,6 +532,8 @@ export default function Layout(props: ParentProps) { if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return dismissSessionAlert(sessionKey) + attention.add(sessionKey) + syncAttention() const toastId = showToast({ persistent: true, @@ -530,6 +554,12 @@ export default function Layout(props: ParentProps) { toastBySession.set(sessionKey, toastId) }) onCleanup(unsub) + onCleanup(() => { + attention.clear() + document.title = baseTitle + const nav = navigator as Navigator & { clearAppBadge?: () => Promise } + void nav.clearAppBadge?.().catch(() => undefined) + }) createEffect(() => { const currentSession = params.id @@ -2067,6 +2097,12 @@ export default function Layout(props: ParentProps) { if (!item) return [] as string[] return workspaceIds(item) }) + const awaiting = createMemo(() => + workspaces().flatMap((directory) => { + const [data] = globalSync.child(directory, { bootstrap: false }) + return awaitingSessions(data, sortNow(), (item) => !permission.autoResponds(item, directory)) + }), + ) const unseenCount = createMemo(() => workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), ) @@ -2230,6 +2266,44 @@ export default function Layout(props: ParentProps) { when={workspacesEnabled()} fallback={ <> + 0}> +
+
+
+
+ Awaiting your input +
+
{awaiting().length}
+
+
+ + {(item) => { + const [data] = globalSync.child(item.session.directory, { bootstrap: false }) + return ( +
+
+ {item.reason === "permission" + ? language.t("notification.permission.title") + : language.t("notification.question.title")} +
+ +
+ ) + }} +
+
+
+
+
+
+ + + {/* Session panel */}
{ }) }) }) + +describe("nextMobileTab", () => { + test("switches blocked mobile views back to session", () => { + expect(nextMobileTab({ current: "changes", blocked: true, mobile: true })).toBe("session") + }) + + test("preserves the current tab when not blocked or not mobile", () => { + expect(nextMobileTab({ current: "changes", blocked: false, mobile: true })).toBe("changes") + expect(nextMobileTab({ current: "changes", blocked: true, mobile: false })).toBe("changes") + }) +}) + +describe("sessionTabAttention", () => { + test("flags the session tab when mobile changes view is blocked", () => { + expect(sessionTabAttention({ current: "changes", blocked: true, mobile: true })).toBe(true) + }) + + test("stays quiet when already on the session tab", () => { + expect(sessionTabAttention({ current: "session", blocked: true, mobile: true })).toBe(false) + }) +}) + +describe("blockedIndicatorVisible", () => { + test("shows only on mobile changes view while blocked", () => { + expect(blockedIndicatorVisible({ current: "changes", blocked: true, mobile: true })).toBe(true) + expect(blockedIndicatorVisible({ current: "session", blocked: true, mobile: true })).toBe(false) + expect(blockedIndicatorVisible({ current: "changes", blocked: false, mobile: true })).toBe(false) + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 7e2c1ccf7b38..e1797ffb077c 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -19,6 +19,15 @@ type TabsInput = { export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` +export const nextMobileTab = (input: { current: "session" | "changes"; blocked: boolean; mobile: boolean }) => + input.mobile && input.blocked ? "session" : input.current + +export const sessionTabAttention = (input: { current: "session" | "changes"; blocked: boolean; mobile: boolean }) => + input.mobile && input.blocked && input.current !== "session" + +export const blockedIndicatorVisible = (input: { current: "session" | "changes"; blocked: boolean; mobile: boolean }) => + input.mobile && input.blocked && input.current === "changes" + export const createSessionTabs = (input: TabsInput) => { const review = input.review ?? (() => false) const hasReview = input.hasReview ?? (() => false) diff --git a/packages/opencode/src/cli/cmd/trigger.ts b/packages/opencode/src/cli/cmd/trigger.ts new file mode 100644 index 000000000000..3a4c46eed947 --- /dev/null +++ b/packages/opencode/src/cli/cmd/trigger.ts @@ -0,0 +1,227 @@ +import type { Argv } from "yargs" +import { EOL } from "os" +import { cmd } from "./cmd" +import { bootstrap } from "../bootstrap" +import { UI } from "../ui" +import { Trigger } from "../../trigger" +import { Locale } from "../../util/locale" +import { SessionID } from "../../session/schema" + +type CreateArgs = { + interval?: number + at?: number + session?: string + command?: string + arguments?: string + webhook?: string + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" + body?: string + webhookSecret?: string +} + +type Action = NonNullable + +export const TriggerCommand = cmd({ + command: "trigger", + describe: "manage triggers", + builder: (yargs: Argv) => + yargs + .command(TriggerListCommand) + .command(TriggerCreateCommand) + .command(TriggerFireCommand) + .command(TriggerDeleteCommand) + .command(TriggerEnableCommand) + .command(TriggerDisableCommand) + .demandCommand(), + async handler() {}, +}) + +export const TriggerListCommand = cmd({ + command: "list", + describe: "list triggers", + builder: (yargs: Argv) => + yargs.option("format", { + describe: "output format", + type: "string", + choices: ["table", "json"], + default: "table", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const items = await Trigger.list() + if (!items.length) return + const output = args.format === "json" ? JSON.stringify(items, null, 2) : formatTriggerTable(items) + process.stdout.write(output + EOL) + }) + }, +}) + +export const TriggerCreateCommand = cmd({ + command: "create", + describe: "create a trigger", + builder: (yargs: Argv) => + yargs + .option("interval", { + describe: "interval in milliseconds", + type: "number", + }) + .option("at", { + describe: "one-time fire time as unix milliseconds", + type: "number", + }) + .option("session", { + describe: "session ID for command actions", + type: "string", + }) + .option("command", { + describe: "command to run for command actions", + type: "string", + }) + .option("arguments", { + describe: "arguments for command actions", + type: "string", + }) + .option("webhook", { + describe: "webhook URL for webhook actions", + type: "string", + }) + .option("method", { + describe: "HTTP method for webhook actions", + type: "string", + choices: ["GET", "POST", "PUT", "PATCH", "DELETE"], + }) + .option("body", { + describe: "HTTP body for webhook actions", + type: "string", + }) + .option("webhook-secret", { + describe: "secret required for external webhook firing", + type: "string", + }), + handler: async (args) => { + await bootstrap(process.cwd(), async () => { + const parsed = parseTriggerCreateInput(args as CreateArgs) + if (typeof parsed === "string") { + UI.error(parsed) + process.exit(1) + } + const item = await Trigger.create(parsed) + process.stdout.write(JSON.stringify(item, null, 2) + EOL) + }) + }, +}) + +const triggerId = (name: string, describe: string) => + cmd({ + command: `${name} `, + describe, + builder: (yargs: Argv) => + yargs.positional("id", { + describe: "trigger ID", + type: "string", + demandOption: true, + }), + async handler() {}, + }) + +export const TriggerFireCommand = { ...triggerId("fire", "fire a trigger now"), handler: runTrigger("fire") } +export const TriggerEnableCommand = { ...triggerId("enable", "enable a trigger"), handler: runTrigger("enable") } +export const TriggerDisableCommand = { ...triggerId("disable", "disable a trigger"), handler: runTrigger("disable") } +export const TriggerDeleteCommand = { ...triggerId("delete", "delete a trigger"), handler: runTrigger("delete") } + +function runTrigger(action: "fire" | "enable" | "disable" | "delete") { + return async (args: { id: string }) => { + await bootstrap(process.cwd(), async () => { + if (action === "delete") { + await Trigger.remove(args.id) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Trigger ${args.id} deleted` + UI.Style.TEXT_NORMAL) + return + } + + const item = + action === "fire" + ? await Trigger.fire(args.id) + : action === "enable" + ? await Trigger.enable(args.id) + : await Trigger.disable(args.id) + process.stdout.write(JSON.stringify(item, null, 2) + EOL) + }) + } +} + +export function parseTriggerCreateInput(args: CreateArgs): Trigger.CreateInput | string { + if (args.interval !== undefined && args.at !== undefined) return "Choose either --interval or --at, not both" + if (args.interval === undefined && args.at === undefined) return "Provide either --interval or --at" + + if (args.webhook && (args.command || args.session)) { + return "Choose either a command action (--session + --command) or a webhook action (--webhook)" + } + + let action: Action | undefined + if (args.webhook) { + action = { + type: "webhook", + url: args.webhook, + ...(args.method ? { method: args.method } : {}), + ...(args.body ? { body: args.body } : {}), + } + } + + if (args.command || args.session) { + if (!args.command || !args.session) return "Command actions require both --session and --command" + action = { + type: "command", + sessionID: SessionID.make(args.session), + command: args.command, + ...(args.arguments ? { arguments: args.arguments } : {}), + } + } + + if (args.interval !== undefined) { + const result: Trigger.CreateInput = { + interval: args.interval, + ...(action ? { action } : {}), + ...(args.webhookSecret ? { webhook_secret: args.webhookSecret } : {}), + } + return result + } + + const result: Trigger.CreateInput = { + schedule: { type: "once", at: args.at! }, + ...(action ? { action } : {}), + ...(args.webhookSecret ? { webhook_secret: args.webhookSecret } : {}), + } + return result +} + +export function formatTriggerTable(items: Trigger.Info[]) { + const lines: string[] = [] + const id = Math.max(12, ...items.map((item) => item.id.length)) + const schedule = Math.max(12, ...items.map((item) => triggerSchedule(item).length)) + const action = Math.max(12, ...items.map((item) => triggerAction(item).length)) + const state = Math.max(8, ...items.map((item) => triggerState(item).length)) + const header = `ID${" ".repeat(id - 2)} Schedule${" ".repeat(schedule - 8)} Action${" ".repeat(action - 6)} State${" ".repeat(state - 5)} Next` + lines.push(header) + lines.push("─".repeat(header.length)) + for (const item of items) { + lines.push( + `${item.id.padEnd(id)} ${triggerSchedule(item).padEnd(schedule)} ${triggerAction(item).padEnd(action)} ${triggerState(item).padEnd(state)} ${Locale.todayTimeOrDateTime(item.time.next)}`, + ) + } + return lines.join(EOL) +} + +function triggerSchedule(item: Trigger.Info) { + return item.schedule.type === "interval" ? `every ${item.schedule.interval}ms` : `once @ ${item.schedule.at}` +} + +function triggerAction(item: Trigger.Info) { + if (!item.action) return "none" + return item.action.type === "command" ? item.action.command : `${item.action.method ?? "GET"} webhook` +} + +function triggerState(item: Trigger.Info) { + if (!item.enabled) return "disabled" + if (!item.last) return "ready" + return item.last.status +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 2da35ace1dd8..7b423a5fc35b 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -28,6 +28,7 @@ import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" import { SessionCommand } from "./cli/cmd/session" +import { TriggerCommand } from "./cli/cmd/trigger" import { DbCommand } from "./cli/cmd/db" import path from "path" import { Global } from "./global" @@ -153,6 +154,7 @@ const cli = yargs(hideBin(process.argv)) .command(GithubCommand) .command(PrCommand) .command(SessionCommand) + .command(TriggerCommand) .command(PluginCommand) .command(DbCommand) .fail((msg, err) => { diff --git a/packages/opencode/test/cli/trigger.test.ts b/packages/opencode/test/cli/trigger.test.ts new file mode 100644 index 000000000000..e178c104c1ff --- /dev/null +++ b/packages/opencode/test/cli/trigger.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test" +import stripAnsi from "strip-ansi" +import { formatTriggerTable, parseTriggerCreateInput } from "../../src/cli/cmd/trigger" +import { SessionID } from "../../src/session/schema" + +describe("trigger cli create parsing", () => { + test("parses interval command triggers", () => { + const result = parseTriggerCreateInput({ + interval: 60_000, + session: "ses_123", + command: "summarize", + arguments: "--daily", + }) + + expect(result).toMatchObject({ + interval: 60_000, + action: { + type: "command", + sessionID: SessionID.make("ses_123"), + command: "summarize", + arguments: "--daily", + }, + }) + }) + + test("parses one-shot webhook triggers", () => { + expect( + parseTriggerCreateInput({ + at: 123, + webhook: "https://example.test/hook", + method: "POST", + body: '{"ok":true}', + webhookSecret: "secret", + }), + ).toEqual({ + schedule: { type: "once", at: 123 }, + action: { + type: "webhook", + url: "https://example.test/hook", + method: "POST", + body: '{"ok":true}', + }, + webhook_secret: "secret", + }) + }) + + test("rejects incomplete or conflicting create args", () => { + expect(parseTriggerCreateInput({})).toBe("Provide either --interval or --at") + expect(parseTriggerCreateInput({ interval: 1, at: 2 })).toBe("Choose either --interval or --at, not both") + expect(parseTriggerCreateInput({ interval: 1, session: "ses_123" })).toBe( + "Command actions require both --session and --command", + ) + expect( + parseTriggerCreateInput({ interval: 1, session: "ses_123", command: "x", webhook: "https://example.test" }), + ).toBe("Choose either a command action (--session + --command) or a webhook action (--webhook)") + }) +}) + +describe("trigger cli table formatting", () => { + test("renders trigger rows with schedule action and state", () => { + const output = stripAnsi( + formatTriggerTable([ + { + id: "trg_1", + schedule: { type: "interval", interval: 60_000 }, + action: { type: "webhook", url: "https://example.test", method: "POST" }, + enabled: true, + runs: 3, + last: { source: "manual", status: "success", time: 1 }, + time: { created: 1, next: 2, last: 1 }, + }, + ]), + ) + + expect(output).toContain("ID") + expect(output).toContain("every 60000ms") + expect(output).toContain("POST webhook") + expect(output).toContain("success") + }) +}) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index d9893503fbda..445277c7c1d5 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -819,6 +819,31 @@ padding-right: 12px; } } + + @media (max-width: 640px) { + [data-slot="permission-footer"] { + flex-direction: column; + align-items: stretch; + gap: 12px; + padding-top: 16px; + margin-top: 0; + + > :first-child { + display: none; + } + } + + [data-slot="permission-footer-actions"] { + flex-direction: column; + align-items: stretch; + width: 100%; + + [data-component="button"] { + width: 100%; + min-height: 44px; + } + } + } } [data-component="dock-prompt"][data-kind="question"] { @@ -1117,6 +1142,39 @@ align-items: center; gap: 8px; } + + @media (max-width: 640px) { + [data-slot="question-body"] { + gap: 12px; + } + + [data-slot="question-option"] { + padding: 14px 12px; + } + + [data-slot="question-footer"] { + flex-direction: column-reverse; + align-items: stretch; + gap: 12px; + padding-top: 16px; + margin-top: 0; + + > [data-component="button"] { + width: 100%; + min-height: 44px; + } + } + + [data-slot="question-footer-actions"] { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + + [data-component="button"] { + min-height: 44px; + } + } + } } [data-component="question-answers"] { diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index e2ba2404de94..c5ed02b304f3 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -78,10 +78,75 @@ opencode attach http://10.20.30.40:4096 #### Flags -| Flag | Short | Description | -| ----------- | ----- | --------------------------------- | -| `--dir` | | Working directory to start TUI in | -| `--session` | `-s` | Session ID to continue | +| Flag | Short | Description | +| ------------- | ----- | ---------------------------------------- | +| `--dir` | | Working directory to start TUI in | +| `--workspace` | | Workspace ID to use on the remote server | +| `--session` | `-s` | Session ID to continue | + +--- + +When you are attaching to a long-running OpenCode server, `--workspace` helps you land in the right remote workspace immediately instead of browsing into it after the connection is established. + +```bash +opencode attach http://your-host:4096 --dir /srv/app --workspace ws_123 --continue +``` + +This is especially useful when you use OpenCode remotely from another laptop or from the web UI on a phone and want to continue the right session without hunting through old work. + +--- + +### trigger + +Manage lightweight scheduled triggers. + +```bash +opencode trigger [command] +``` + +Use triggers when you want OpenCode to do something later, on a schedule, or when another tool hits a webhook. + +#### list + +List the current triggers for the active project. + +```bash +opencode trigger list +``` + +#### create + +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}' +``` + +Command actions run an OpenCode command in a session. Webhook actions send an HTTP request to another service. + +#### fire + +Run a trigger immediately without waiting for its schedule. + +```bash +opencode trigger fire +``` + +#### enable / disable / delete + +```bash +opencode trigger enable +opencode trigger disable +opencode trigger delete +``` + +Use `disable` when you want to pause a trigger without losing its configuration. --- diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/server.mdx index 4510bd4981fe..34cd5ae27a6f 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/server.mdx @@ -69,6 +69,58 @@ The [`/tui`](#tui) endpoint can be used to drive the TUI through the server. For --- +### Scheduled triggers + +OpenCode can register lightweight triggers on a running instance. Use them when you want OpenCode to do work later, on a schedule, or when another system hits a webhook. + +Today, triggers support two scheduling modes: + +- `interval` — run repeatedly after a fixed number of milliseconds +- `once` — run once at a specific Unix millisecond timestamp + +And they support two action types: + +- `command` — run an OpenCode command in a session +- `webhook` — send an HTTP request to another service + +For example, this can be used to wake up OpenCode every morning, run a recurring command against an existing session, or forward a scheduled event into an external automation system. + +If you prefer CLI management instead of raw API calls, see [CLI](/docs/cli#trigger). + +--- + +### Remote control from browser or phone + +Because OpenCode uses a client/server architecture, you can keep a server running on one machine and control it from another browser, another computer, or a phone. + +Typical setup: + +```bash +export OPENCODE_SERVER_PASSWORD='choose-a-strong-password' +opencode web --hostname 0.0.0.0 --port 4096 +``` + +Then: + +- open the web UI from another device +- or attach a TUI from another computer with `opencode attach` + +```bash +opencode attach http://your-host:4096 --dir /srv/app --workspace ws_123 --continue +``` + +The current remote-control flow is designed to make blocked sessions easier to recover when you are away from the terminal: + +- workspace-aware remote attach +- session continue and fork flows +- an awaiting-input inbox in the web app +- mobile session attention states and blocked-session indicators +- browser notification, title, and badge attention when OpenCode needs input + +This makes it practical to leave OpenCode running on one machine and answer questions later from a browser on your phone or another device. + +--- + ## Spec The server publishes an OpenAPI 3.1 spec that can be viewed at: @@ -189,6 +241,21 @@ The opencode server exposes the following APIs. --- +### Triggers + +| Method | Path | Description | Response | +| -------- | --------------------------- | ------------------------------------------- | ------------------------------------------------------------------------ | +| `GET` | `/trigger` | List triggers for the current instance | Trigger[] | +| `POST` | `/trigger` | Create a trigger | body: trigger input, returns Trigger | +| `GET` | `/trigger/:id` | Get a single trigger | Trigger | +| `POST` | `/trigger/:id/fire` | Fire a trigger immediately | Trigger | +| `POST` | `/trigger/:id/fire/webhook` | Fire a trigger through its webhook endpoint | Trigger | +| `POST` | `/trigger/:id/enable` | Enable a trigger | Trigger | +| `POST` | `/trigger/:id/disable` | Disable a trigger | Trigger | +| `DELETE` | `/trigger/:id` | Delete a trigger | `{ success: true }` | + +--- + ### Files | Method | Path | Description | Response |