settings refactor

This commit is contained in:
2026-02-16 23:39:06 +00:00
parent 9f720e1804
commit c21353dbba
13 changed files with 322 additions and 14 deletions
+7 -7
View File
@@ -4,7 +4,7 @@ import { useServerSettings } from '@/state/useServerSettings';
import { useInitialData } from '@/state/useInitialData';
import { LandingPage, AuthLayout } from './Screens/LandingPage';
import { Home } from './Screens/Dashboard/Home';
import { Profile } from './Screens/Dashboard/Profile';
import { ProfileSettings } from './Screens/Dashboard/Settings/ProfileSettings';
import { ClaudeSessions, ClaudeChat, OpenCodeChat, NewChat } from './Screens/Dashboard/Chat';
import { Plans } from './Screens/Dashboard/Plans';
import { Skills } from './Screens/Dashboard/Skills';
@@ -15,8 +15,8 @@ import { SignoutScreen } from './Screens/Dashboard/SignoutScreen';
import { Screen as Files } from 'plugins/FileBrowser/client';
import { Screen as Terminal } from 'plugins/Terminal/client';
import { AISettings } from './Screens/Dashboard/Settings/AISettings';
import { ServerSettings } from './Screens/Dashboard/ServerSettings';
import { Applications } from './Screens/Dashboard/Applications';
import { ServerSettings } from './Screens/Dashboard/Settings/ServerSettings';
import { ResourceSettings } from './Screens/Dashboard/Settings/ResourceSettings';
import { OnboardingAdmin } from './Screens/Dashboard/OnboardingAdmin';
export function App() {
@@ -45,7 +45,10 @@ export function App() {
{isAuthenticated && onboardingComplete && (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings/profile" element={<Profile />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/ai" element={<AISettings />} />
<Route path="/settings/server" element={<ServerSettings />} />
<Route path="/settings/resources" element={<ResourceSettings />} />
<Route path="/chat" element={<ClaudeSessions />} />
<Route path="/chat/new" element={<NewChat />} />
<Route path="/chat/:sessionId" element={<ClaudeChat />} />
@@ -53,9 +56,6 @@ export function App() {
<Route path="/chat/opencode/:sessionId" element={<OpenCodeChat />} />
{plugins?.FileBrowser !== false && <Route path="/files" element={<Files />} />}
{plugins?.Terminal !== false && <Route path="/terminal" element={<Terminal />} />}
<Route path="/settings/ai" element={<AISettings />} />
<Route path="/settings/server" element={<ServerSettings />} />
<Route path="/settings/applications" element={<Applications />} />
<Route path="/plans" element={<Plans />} />
<Route path="/skills" element={<Skills />} />
<Route path="/tasks" element={<Tasks />} />
@@ -114,9 +114,9 @@ export function DashboardLayout({ children, mobileFull }: DashboardLayoutProps)
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/applications">
<Link to="/settings/resources">
<Package className="mr-2 h-4 w-4" />
Applications
Resources
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
@@ -3,7 +3,7 @@ import { Search, User, Lock, Globe, ListChecks } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Card } from '@/components/Card';
import { DashboardLayout } from '../Layout';
import { DashboardLayout } from '../../Layout';
import { UserData } from './UserData';
import { ChangePassword } from './ChangePassword';
import { Languages } from './Languages';
@@ -42,7 +42,7 @@ const sections = [
const allKeys = sections.map((s) => s.key);
export const Profile = () => {
export const ProfileSettings = () => {
const [search, setSearch] = useState('');
const [expanded, setExpanded] = useState<string[]>([]);
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({});
@@ -5,7 +5,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useClient } from 'hooks/useClient';
import { DashboardLayout } from '../Layout';
import { DashboardLayout } from '../../Layout';
type AppStatus = {
id: string;
@@ -43,7 +43,7 @@ const CopyCommand = ({ command }: { command: string }) => {
);
};
export const Applications = () => {
export const ResourceSettings = () => {
const client = useClient();
const queryClient = useQueryClient();
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
@@ -3,7 +3,7 @@ import { Search, Terminal, Puzzle, Shield } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion';
import { Card } from '@/components/Card';
import { DashboardLayout } from '../Layout';
import { DashboardLayout } from '../../Layout';
import { AIHarnessesSection } from './AIHarnessesSection';
import { PluginsSection } from './PluginsSection';
import { TerminalSection } from './TerminalSection';
@@ -0,0 +1,308 @@
# Terminal Plugin Spec
This document describes the Terminal plugin implementation, architecture, and integration points. It is a reference for continuing work later.
## Goals
- Provide a browser terminal (xterm.js) inside Officer.
- Support two execution modes:
- Host mode: shell runs on the host machine.
- Sandbox mode: shell runs inside a Docker sidecar container.
- Keep host mode working and optional; sandbox is enabled via admin settings.
- Keep all terminal logic self-contained in the plugin.
- Allow the terminal UI to be rendered anywhere with a reusable component.
## File Layout
- `src/workspaces/plugins/Terminal/index.ts`
- Exports plugin metadata, client screen, and server websocket handler.
- `src/workspaces/plugins/Terminal/client/TerminalView.tsx`
- Reusable terminal component (xterm + websocket bridge).
- `src/workspaces/plugins/Terminal/client/Screen/index.tsx`
- Page wrapper that renders `TerminalView` inside `DashboardLayout`.
- `src/workspaces/plugins/Terminal/client/index.ts`
- Exports `Screen` and `TerminalView`.
- `src/workspaces/plugins/Terminal/server/websocket.ts`
- WebSocket handler for terminal sessions and sidecar orchestration.
- `src/workspaces/plugins/Terminal/server/pty-sidecar.mjs`
- Node sidecar process that owns `node-pty` and shell spawn.
- `src/workspaces/plugins/Terminal/server/Dockerfile.terminal-sidecar`
- Docker image for sandbox mode.
- `src/workspaces/plugins/Terminal/server/templates/.zshrc`
- `src/workspaces/plugins/Terminal/server/templates/starship-officer.toml`
- Template configs copied into user home for stable shell UX.
## Client Architecture
### TerminalView
`TerminalView` is the reusable component that can be rendered anywhere.
Props:
- `className?: string`
- `style?: CSSProperties`
- `wsPath?: string` (default: `/api/terminal/ws`)
- `fontSize?: number` (default: 14)
- `fontFamily?: string`
- `theme?: { background?; foreground?; cursor?; selectionBackground? }`
- `autoFocus?: boolean` (default: true)
- `onReady?: (term: XTerm) => void`
- `onExit?: () => void`
- `onDisconnect?: () => void`
Behavior:
- Initializes xterm after mount for StrictMode compatibility.
- Connects to websocket at `wsPath`, appending `token` from localStorage.
- Sends `resize` on open, and on ResizeObserver changes.
- Forwards terminal input as `{ type: 'input', data }` JSON.
- Writes server output on `{ type: 'output', data }`.
- Emits `onExit` when `{ type: 'exit' }` is received.
- Emits `onDisconnect` when the websocket closes.
### Screen
`Screen` is a thin wrapper around `TerminalView` for the full-page route:
```
<DashboardLayout>
<TerminalView className="h-full w-full p-2" />
</DashboardLayout>
```
This matches plugin conventions used by FileBrowser.
## Server Architecture
### WebSocket Flow
The terminal websocket is mounted in `src/server.tsx`:
- Path: `/api/terminal/ws`
- Auth: `token` query parameter, validated with `verify()`.
- Server handler: `terminalWebsocket` from `plugins/Terminal/server`.
Message formats between client and server:
- Client -> server:
- `{ type: 'input', data: string }`
- `{ type: 'resize', cols: number, rows: number }`
- Server -> client:
- `{ type: 'output', data: string }`
- `{ type: 'exit' }`
### Sidecar Model
The Bun server does not spawn PTYs directly. Instead it proxies to a Node sidecar
that runs `node-pty` for reliability.
There are two sidecar modes:
1. Host mode
- Spawns a local Node sidecar on the host via Bun.
- Sidecar binds to `127.0.0.1`.
2. Docker sandbox mode
- Runs a Docker container per user.
- Container binds to `0.0.0.0` and is mapped to a host port.
- Container runs the same Node sidecar and shell inside the container.
### Mode Selection
- Settings are stored in `~/.config/officer.dev/server-settings.json`.
- `terminalSandboxed: true` enables Docker mode.
- If `terminalSandboxed` is falsy, host mode is used.
### Sidecar Handshake
On websocket open:
- Determine user home: `getHomeDir(email)`.
- Ensure the home directory exists.
- Determine mode (host or docker).
- Connect to sidecar on a port (host: default 5337; docker: user-specific).
- Send init payload to sidecar:
```
{
type: 'init',
shell: { command, args, name },
cwd,
homeDir,
userLabel
}
```
- `userLabel` is the email used by the prompt template.
- `cwd` and `homeDir` are `/home/officer` in Docker, or the user home on host.
### Docker Container Lifecycle
Containers are named `officer-terminal-${userId}` and stored per user in:
`data/terminal-containers.json`
Rules:
- Reuse an existing container if it exists and is running.
- Restart the container if it exists but stopped.
- Start a new container if none exists; allocate a port starting at 54000.
- Containers are left running for reuse when websocket closes.
### Docker Image
- Tag: `officer-terminal-sidecar:v1`
- Built from `Dockerfile.terminal-sidecar` when missing.
Includes:
- zsh, git, curl, ca-certificates
- oh-my-zsh
- starship
- fortune-mod, cowsay
- eza
- node, ws, node-pty
### Shell Configuration
Templates are copied into the user's home if missing:
- `.zshrc`
- `.config/starship-officer.toml`
This provides a stable base for all users while allowing per-user customization.
Prompt requirement:
- Format: `email@domain:officer.dev`
- Email colored in button blue (#0891B2)
## Plugin Conventions
- `index.ts` exports `plugin` metadata with `id` matching folder name.
- Client exports use `Widget`/`Screen` conventions. Terminal only exports `Screen`
and `TerminalView` (no widget).
- Server exports a websocket handler (not a router). This is a special-case
plugin that is mounted in `src/server.tsx`.
## Known Constraints
- Terminal mode is global via server settings; no per-component mode selection.
- `TerminalView` builds the websocket URL from `window.location` and localStorage
token; custom auth is not supported yet.
- Sidecar connection retries are bounded; errors are printed to the terminal.
## Potential Future Enhancements
- Allow `TerminalView` to request mode/cwd via websocket init payload.
- Add a read-only mode or initial command support.
- Add per-component theme presets or allow app-level defaults.
- Add better telemetry/logging for sidecar startup failures.
## API Contract
This section describes the messages across each layer.
### Client <-> Server (Bun)
Client -> server:
- `input`
- `{ type: 'input', data: string }`
- `data` is raw terminal input from xterm.
- `resize`
- `{ type: 'resize', cols: number, rows: number }`
Server -> client:
- `output`
- `{ type: 'output', data: string }`
- `data` is raw PTY output to be written to xterm.
- `exit`
- `{ type: 'exit' }`
- Signals terminal process termination.
### Server <-> Sidecar (Node)
Server -> sidecar:
- `init`
- `{ type: 'init', shell, cwd, homeDir, userLabel }`
- `shell`: `{ command: string; args: string[]; name: string }`
- `cwd`: initial current working directory
- `homeDir`: home directory to set in the sidecar
- `userLabel`: email for the prompt template
- `input`
- `{ type: 'input', data: string }`
- `resize`
- `{ type: 'resize', cols: number, rows: number }`
Sidecar -> server:
- `output`
- `{ type: 'output', data: string }`
- `exit`
- `{ type: 'exit' }`
## Sequence Diagram
```
Client (xterm) Bun Server Sidecar (Node) Shell
| | | |
| WS connect | | |
|---------------> | | |
| | connect sidecar | |
| |-------------------> | |
| | init (shell/cwd) | |
| |-------------------> | spawn PTY |
| | |----------------->|
| input | | |
|---------------> | input | |
| |-------------------> | write to PTY |
| output | | |
|<--------------- | output | read from PTY |
| |<------------------- | |
| resize | | |
|---------------> | resize | |
| |-------------------> | set PTY size |
```
## Docker Troubleshooting
Common failure modes and checks:
- Docker not installed or not in PATH
- `ensureDockerImage()` will throw. Install Docker or fix PATH.
- Image build fails
- Build uses `Dockerfile.terminal-sidecar` and tag `officer-terminal-sidecar:v1`.
- Check for network access (apt), and that build context is the plugin folder.
- Container starts but sidecar is unreachable
- Container binds to `0.0.0.0`, host maps `127.0.0.1:${port}:${port}`.
- Port is user-specific, stored in `data/terminal-containers.json`.
- Verify with `docker ps` and `docker logs` for the container name.
- Permissions inside container
- Container runs with host UID/GID derived from the mounted user home.
- If home dir ownership is incorrect, zsh history may fail to write.
- Shell missing
- Container must have zsh installed; the Dockerfile includes zsh.
- Host mode resolves shell from `$SHELL` or known paths.
## Per-User Template Rules
Templates are copied into user home on first launch only.
Files:
- `.zshrc`
- `.config/starship-officer.toml`
Rules:
- If a file already exists, it is not overwritten.
- This lets the admin provide a stable default while allowing user customization.
- To reset a user to defaults, delete the per-user files and reconnect.