Skip to content
Open
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
106 changes: 106 additions & 0 deletions docs/KIOSK_MODE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# OpenCode Kiosk Mode

## Overview

Kiosk mode provides a simplified web UI experience by hiding navigation elements and optionally auto-navigating to a new session. This is useful for:

- Embedded deployments
- Simplified onboarding experiences
- Kiosk terminals
- Single-project focused workflows

## URL Parameters

| Parameter | Required | Description |
|-----------|----------|-------------|
| `kiosk=true` | Yes | Enables kiosk mode - hides sidebar and header |
| `dir=/path/to/project` | No | Auto-opens project and navigates to new session |
| `url=http://host:port` | No | Backend server URL (for remote/dev setups) |

## Usage Examples

### Basic Kiosk Mode
Hides navigation, user selects project manually:
```
http://localhost:4096/?kiosk=true
```

### Kiosk Mode with Auto-Project
Hides navigation and auto-opens specific project:
```
http://localhost:4096/?kiosk=true&dir=/home/user/my-project
```

### Development/Remote Setup
Connect to remote backend with kiosk mode:
```
http://localhost:3000/?url=http://192.168.1.100:4096&kiosk=true&dir=/path/to/project
```

## What Gets Hidden

In kiosk mode, the following UI elements are hidden:

1. **Left Sidebar** (desktop and mobile)
- Project list
- Session navigation
- Open project button
- Provider connection

2. **Top Header Bar**
- Project selector dropdown
- Session selector dropdown
- Server status indicator
- LSP/MCP indicators
- Share button
- Review/Terminal toggles

## What Remains Visible

- Main session workspace (chat interface)
- Terminal panel (if opened via keyboard shortcut)
- Review panel (if opened via keyboard shortcut)
- Toast notifications
- Dialog modals (permissions, file selection, etc.)

## Implementation Details

### Files Modified

| File | Changes |
|------|---------|
| `packages/app/src/context/layout.tsx` | Added `kiosk.enabled()` and `kiosk.dir()` state |
| `packages/app/src/pages/layout.tsx` | Conditional sidebar rendering |
| `packages/app/src/components/session/session-header.tsx` | Conditional header rendering |
| `packages/app/src/pages/home.tsx` | Auto-redirect logic for `dir` parameter |

### How It Works

1. URL parameters are read once at module load time
2. `layout.kiosk.enabled()` returns true if `?kiosk=true` is present
3. `layout.kiosk.dir()` returns the directory path if `?dir=` is present
4. Sidebar and header check `kiosk.enabled()` and hide themselves
5. Home page checks for kiosk mode + dir and auto-navigates to session

## Keyboard Shortcuts

Even in kiosk mode, keyboard shortcuts remain functional:

- `Ctrl+P` / `Cmd+P` - Open command palette
- Terminal and review panel shortcuts still work

## Limitations

- Parameters are read at page load; changing URL params requires page refresh
- No way to exit kiosk mode without removing URL parameter
- Directory path must be accessible by the OpenCode server

## Future Enhancements

Potential improvements for consideration:

- [ ] Session ID parameter to open specific session
- [ ] Theme parameter for kiosk-specific theming
- [ ] Read-only mode parameter
- [ ] Auto-hide prompt input option
- [ ] Configurable visible elements
3 changes: 3 additions & 0 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export function SessionHeader() {
navigate(`/${params.dir}/session/${session.id}`)
}

// Hide header in kiosk mode
if (layout.kiosk.enabled()) return null

return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex">
<button
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { persisted } from "@/utils/persist"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"

// Check for kiosk mode from URL parameters (read once at load time)
const kioskParams = new URLSearchParams(window.location.search)
const kioskEnabled = kioskParams.get("kiosk") === "true"
const kioskDir = kioskParams.get("dir") // Optional directory to auto-open in kiosk mode

const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]

Expand Down Expand Up @@ -244,6 +249,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(

return {
ready,
kiosk: {
enabled: () => kioskEnabled,
dir: () => kioskDir,
},
projects: {
list,
open(directory: string) {
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/pages/home.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useGlobalSync } from "@/context/global-sync"
import { createMemo, For, Match, Show, Switch } from "solid-js"
import { createMemo, For, Match, onMount, Show, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { useLayout } from "@/context/layout"
Expand All @@ -22,6 +22,15 @@ export default function Home() {
const server = useServer()
const homedir = createMemo(() => sync.data.path.home)

// Auto-redirect to session in kiosk mode with dir parameter
onMount(() => {
if (layout.kiosk.enabled() && layout.kiosk.dir()) {
const dir = layout.kiosk.dir()!
layout.projects.open(dir)
navigate(`/${base64Encode(dir)}/session`)
}
})

function openProject(directory: string) {
layout.projects.open(directory)
navigate(`/${base64Encode(directory)}`)
Expand Down
104 changes: 53 additions & 51 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1051,65 +1051,67 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<div class="flex-1 min-h-0 flex">
<div
classList={{
"hidden xl:block": true,
"relative shrink-0": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
>
<Show when={!layout.kiosk.enabled()}>
<div
classList={{
"@container w-full h-full pb-5 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
"hidden xl:block": true,
"relative shrink-0": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
>
<SidebarContent />
<div
classList={{
"@container w-full h-full pb-5 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}}
>
<SidebarContent />
</div>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</Show>
</div>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
</Show>
</div>
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
<A
href="/"
class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
onClick={() => layout.mobileSidebar.hide()}
>
<Mark class="shrink-0" />
</A>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
<div class="border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0">
<A
href="/"
class="shrink-0 h-8 flex items-center justify-start px-2 w-full"
onClick={() => layout.mobileSidebar.hide()}
>
<Mark class="shrink-0" />
</A>
</div>
<SidebarContent mobile />
</div>
<SidebarContent mobile />
</div>
</div>
</Show>

<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
</div>
Expand Down