This commit is contained in:
2026-02-23 22:52:27 +00:00
parent 8fb96c7cf8
commit 2126f3912e
35 changed files with 1126 additions and 137 deletions
+126 -63
View File
@@ -2,26 +2,50 @@
Guide for agentic coding assistants working in the Officer monorepo. Guide for agentic coding assistants working in the Officer monorepo.
## What Is Officer
Officer is an **AI-powered intranet server** for small and medium businesses. It's a self-hosted platform that gives each team member a personal AI assistant, file storage, terminal, code editor, workspaces, and project management — all under centralized admin control.
**Think of it as**: a self-hosted, AI-native company intranet where every employee gets their own workspace with shared organizational resources and automation.
### Multi-User Architecture
- **Role hierarchy**: Member → Admin → Owner → Super Admin
- **Bootstrap flow**: First user registers as Super Admin, then invites the team
- **Per-user isolation**: Files, sessions, settings, workspaces, and tasks are scoped per user under `$DATA_PATH/{email}/`
- **Shared org resources**: Global tasks/skills/processes, server-level settings (SMTP, AI providers, TTS/STT/OCR), pluggable applications and resources
- **Multi-scope resolution**: Tasks, skills, and processes resolve user → global → native (built-in), enabling org-wide shared automation
### Core Capabilities
1. **AI Chat** — Multi-provider (Claude, OpenCode, Pi-Mono) with sessions, attachments, speech-to-text, slash commands
2. **File Browser** — Full filesystem access per user (upload, mkdir, copy, move, delete)
3. **File Viewer** — Preview video, images, code, text, markdown
4. **Terminal** — WebSocket-based PTY terminal with Docker sandboxing
5. **Code Editor** — Monaco-based IDE with file tabs
6. **Projects** — Project management with per-project workspace layouts, git init
7. **Workspaces** — Customizable panel-based layouts (split, resize, swap, drag)
8. **Automation/Skills/Tasks/Processes** — Markdown-based capability definitions with YAML frontmatter
9. **Dev Server** — Start/stop project dev servers with auto-port discovery and live proxy
10. **Dashboard Widgets** — Clock, weather, pomodoro, daily goals, quick notes
11. **Settings** — User preferences, server config, resource management
## Quick Start Commands ## Quick Start Commands
### Development ### Development
```bash ```bash
bun dev # Dashboard + API server (port 5000) bun dev # Dashboard + API server (port 5000)
bun dev:tracking # Tracking server (port 5001) bun dev:emailer # Emailer workspace
bun dev:experiments # Experiments server
bun dev:emailer # Emailer workspace
``` ```
### Building & Database ### Building
```bash ```bash
bun run prebuild # Run prebuild tasks bun run prebuild # Run prebuild tasks
bun run build:web # Build web app
bun run build:dashboard # Build dashboard bun run build:dashboard # Build dashboard
bun run build:editor # Build editor (app + extension + runtime) bun run build:editor # Build editor (app + extension + runtime)
bun run build:runtime # Build all runtime scripts
bun run db:gen && db:push # Generate & push officer_db migrations
bun run db:gen:stats && db:push:stats # Generate & push statistics_db
``` ```
### Code Quality ### Code Quality
@@ -29,11 +53,82 @@ bun run db:gen:stats && db:push:stats # Generate & push statistics_db
```bash ```bash
bun format # Format all files (Prettier, required before commit) bun format # Format all files (Prettier, required before commit)
bun format:check # Check formatting without writing bun format:check # Check formatting without writing
bunx tsgo # TypeScript type checking (comprehensive, slow) bunx tsgo # TypeScript type checking
``` ```
**Note:** No automated tests configured yet. Always run `bun format` before committing. **Note:** No automated tests configured yet. Always run `bun format` before committing.
## Project Structure
```
src/
├── apps/
│ └── officer-web/ # Main web UI (React 19)
│ ├── Screens/
│ │ ├── Authentication/ # Login, verify, reset password
│ │ └── Dashboard/ # All main screens (Home, Files, Chat, Terminal, Projects, etc.)
│ ├── state/ # App-specific state hooks
│ ├── lib/ # Utilities
│ └── locales/ # i18n translations
├── servers/
│ ├── api/ # REST API (Hono, port 5000)
│ │ ├── auth/ # Authentication (JWT + WebAuthn passkeys)
│ │ ├── users/ # User management (invite, CRUD)
│ │ ├── sessions/ # Multi-provider chat sessions
│ │ ├── workspaces/ # Workspace & project state
│ │ ├── tasks/ # Task definitions (CRUD + chat)
│ │ ├── skills/ # Skill definitions (CRUD + chat)
│ │ ├── processes/ # Process definitions (CRUD + chat)
│ │ ├── file-browser/ # Filesystem access (multi-root)
│ │ ├── terminal/ # WebSocket PTY terminal
│ │ ├── dev-server/ # Project dev server management
│ │ ├── pi/ # AI agent integration
│ │ ├── scrape/ # Web scraping (Playwright)
│ │ ├── upload/ # File uploads
│ │ ├── settings/ # User settings & state
│ │ ├── server-settings/ # Server-wide config (SMTP, AI, TTS, etc.)
│ │ ├── dock/ # Dock configuration
│ │ ├── plans/ # Markdown plans
│ │ ├── task-logs/ # Task execution logs
│ │ └── landing-page-data/# Registration status
│ └── _middlewares/ # Auth, rate limiting, CORS, body parsing
├── databases/
│ └── officer_db/ # JSON file-based auth store (users, passkeys, tokens)
└── workspaces/ # 13 shared packages
├── types/ # Central type re-exports
├── definitions/ # Constants, enums (roles, statuses, devices)
├── config/ # URL configs, env vars
├── helpers/ # cn(), formatters, slug, debounce, queue
├── hooks/ # 90+ hooks (useClient, useForm, useAuth, etc.)
├── state/ # React Query state hooks (useSettings, useChatSessions, etc.)
├── components/ # 89 components (shadcn/ui base + custom)
├── officerdev/ # Core workspace/panel framework + 11 built-in apps
├── i18n/ # Internationalization
├── injector/ # DOM manipulation for visual editing
├── widgets/ # Dashboard widgets (clock, weather, pomodoro, etc.)
├── emailer/ # React-email templates + SMTP
└── sounds/ # Audio feedback library
```
## Path Aliases
- `@/``src/apps/officer-web/`
- `@@/``src/servers/`
- `@/components/*``src/workspaces/components/*`
## Tech Stack
- **Runtime**: Bun
- **Language**: TypeScript 5.9 (strict mode, verbatimModuleSyntax)
- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui
- **Backend**: Hono framework, JWT auth, WebAuthn passkeys
- **Storage**: JSON file-based (auth store + user data), no traditional DB for most data
- **Build**: Vite
- **AI**: Claude Agent SDK, multi-provider support
## Code Style Guide ## Code Style Guide
### Imports ### Imports
@@ -48,9 +143,15 @@ import { useExperiment } from 'hooks/use-experiment';
import { formatDate } from '../helpers'; import { formatDate } from '../helpers';
``` ```
Type-only imports required (verbatimModuleSyntax):
```ts
import { ActionModals, type ActionModalsTypes } from './ActionModals';
import type { FormEvent } from 'react';
```
### TypeScript ### TypeScript
- Strict mode always - no `any` - Strict mode always no `any`
- Prefer `type` over `interface` - Prefer `type` over `interface`
- Colocate prop types with components as named exports - Colocate prop types with components as named exports
- Early returns for null/undefined guards - Early returns for null/undefined guards
@@ -61,12 +162,11 @@ import { formatDate } from '../helpers';
- Arrow functions for simple/one-liners - Arrow functions for simple/one-liners
- Regular functions for complex multi-line logic - Regular functions for complex multi-line logic
- Named exports only (never default exports) - Named exports only (never default exports)
- Extract params type when signature gets long (no multiline params)
```ts ```ts
export const formatDate = (ts: number) => new Date(ts).toLocaleDateString(); export const formatDate = (ts: number) => new Date(ts).toLocaleDateString();
export function calculateStats(data: DataPoint[]) { export function calculateStats(data: DataPoint[]) { /* ... */ }
/* ... */
}
``` ```
### React Components ### React Components
@@ -82,23 +182,13 @@ export const Card = ({ title, onClick }: CardProps) => <div onClick={onClick}>{t
### State Management ### State Management
- **Manager pattern** for complex hooks - return object with state + methods - **React Query** for server state
- **Colocation** - all feature state in one hook - **useGlobal()** for UI state (backed by query cache, no Context needed)
- **Derived state** - compute in hook, not in components - **useWorkspacesState()** for persistent workspace layouts (server-synced)
- **useQueryState()** for URL-synced state
```ts - **usePanelChannel()** for inter-panel pub/sub communication
export const useExperimentManager = (id: number) => { - **Manager pattern** for complex hooks — return object with state + methods
const [exp, setExp] = useState<Experiment | null>(null); - **Derived state** — compute in hook, not in components
const isActive = exp?.status === 'running';
return {
exp,
isActive,
update: (data) => {
/* ... */
},
};
};
```
### Naming Conventions ### Naming Conventions
@@ -118,40 +208,13 @@ export const useExperimentManager = (id: number) => {
- Use try/catch for async operations - Use try/catch for async operations
- Include context in error messages (IDs, resource names) - Include context in error messages (IDs, resource names)
- Log with `console.error` and re-throw appropriately - Always async/await (never .then() chains)
- Handle database connection errors explicitly
### Async/Await
- Always use async/await (never .then() chains)
- Minimize nesting
## Project Structure
- `src/apps/dashboard/` - Admin UI (React 19)
- `src/apps/editor/` - Visual editor
- `src/servers/api/` - REST API (Hono, port 5000)
- `src/servers/tracking/` - Event collection (port 5001)
- `src/databases/` - PostgreSQL schemas (3 DBs: officer_db, statistics_db, ephemeral_db)
- `src/workspaces/` - Shared: components, hooks, helpers, types
## Path Aliases
- `@/``src/apps/dashboard/`
- `@@/``src/servers/`
## Tech Stack
- **Runtime**: Bun
- **Language**: TypeScript 5.9 (strict mode)
- **Frontend**: React 19, React Router, React Query, Tailwind CSS, shadcn/ui
- **Backend**: Hono, PostgreSQL, Drizzle ORM
- **Build**: Vite
## General Guidelines ## General Guidelines
- **Database-first** approach - schema → API → UI - **Database-first** approach schema → API → UI
- **Self-documenting code** - clear naming, minimal comments - **Self-documenting code** clear naming, minimal comments
- **Explicit over implicit** - no magic - **Explicit over implicit** no magic
- **Workspace dependencies** - use `workspace:*` - **Workspace dependencies** use `workspace:*`
- **Environment variables** - use `.env`, access directly in code - **Multi-user aware** — always consider user isolation and role-based access when adding features
- **File-based storage** — user data lives under `$DATA_PATH/{email}/`, respect the per-user boundary
+1 -1
View File
@@ -1,5 +1,5 @@
--- ---
name: mlx.audio name: mlxaudio
description: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon. description: Generate speech from text and transcribe audio using mlx-audio. Use when the user wants text-to-speech synthesis, speech-to-text transcription, voice cloning, audio separation, or speech-to-speech processing on Apple Silicon.
--- ---
@@ -1,5 +1,5 @@
--- ---
name: whisper.cpp name: whisper-cpp
description: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file. description: Transcribe audio files to text using whisper.cpp. Use when the user wants to transcribe audio, convert speech to text, or extract text from an audio/video file.
--- ---
+1
View File
@@ -40,6 +40,7 @@ export function App() {
<Route path="/settings/system" element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />} /> <Route path="/settings/system" element={user?.role !== 'Member' ? <Dashboard.SystemSettings /> : <Navigate to="/" replace />} />
<Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} /> <Route path="/settings/resources" element={user?.role !== 'Member' ? <Dashboard.ResourceSettings /> : <Navigate to="/" replace />} />
<Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} /> <Route path="/settings/users" element={user?.role === 'Super Admin' ? <Dashboard.UserSettings /> : <Navigate to="/" replace />} />
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
<Route path="/automation" element={<Dashboard.Automation />} /> <Route path="/automation" element={<Dashboard.Automation />} />
<Route path="/chat" element={<Dashboard.SessionListPage />} /> <Route path="/chat" element={<Dashboard.SessionListPage />} />
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} /> <Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
@@ -11,7 +11,7 @@ import { useGlobal } from 'hooks/useGlobal';
const initialState: LoginFormState = { const initialState: LoginFormState = {
// email: 'pastilhas@pastilhas.dev', // email: 'pastilhas@pastilhas.dev',
// password: '1234567890', // password: '',
}; };
export function Login() { export function Login() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -70,6 +70,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
<div className="h-full w-full pt-2"> <div className="h-full w-full pt-2">
<WorkspaceView <WorkspaceView
workspace={workspace} workspace={workspace}
locked
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => { onMobilePanelChange={(id) => {
if (!id) navigate('/chat', { replace: true }); if (!id) navigate('/chat', { replace: true });
@@ -9,7 +9,7 @@ export const FilesScreen = () => {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView workspace={workspace} ephemeral={ephemeral} /> <WorkspaceView workspace={workspace} locked ephemeral={ephemeral} />
</div> </div>
); );
}; };
@@ -1,9 +1,17 @@
import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev'; import type { LayoutNode, PanelComponents, DefaultFileSort } from 'officerdev';
import { WorkspaceView, useFileViewerPanels } from 'officerdev'; import { WorkspaceView, useFileViewerPanels } from 'officerdev';
import { useWorkspacesState } from 'state/useWorkspacesState'; import { useWorkspacesState } from 'state/useWorkspacesState';
import { useSettings } from 'state/useSettings';
import { Button } from '@/components/ui/button';
import { defaultLayout } from './defaultLayout'; import { defaultLayout } from './defaultLayout';
const HomeHeader = () => { const HomeHeader = () => {
const { settings, saveSettings } = useSettings();
const completeOnboarding = () => {
saveSettings({ ...settings, onboarding: { complete: true } });
};
return ( return (
<div className="flex h-full items-center justify-center p-6 text-center"> <div className="flex h-full items-center justify-center p-6 text-center">
<div> <div>
@@ -11,6 +19,9 @@ const HomeHeader = () => {
<p className="mt-2 text-sm opacity-70"> <p className="mt-2 text-sm opacity-70">
Please follow the video instructions below in order to get familiar with all that is possible. Please follow the video instructions below in order to get familiar with all that is possible.
</p> </p>
<Button className="mt-6" onClick={completeOnboarding}>
I'm ready
</Button>
</div> </div>
</div> </div>
); );
@@ -25,10 +36,26 @@ const defaultSort: DefaultFileSort = { field: 'type', direction: 'desc' };
export const HomeScreen = () => { export const HomeScreen = () => {
const workspace = useWorkspacesState<LayoutNode>('screens/home', defaultLayout); const workspace = useWorkspacesState<LayoutNode>('screens/home', defaultLayout);
const ephemeral = useFileViewerPanels(); const ephemeral = useFileViewerPanels();
const { settings } = useSettings();
if (settings.onboarding.complete) {
return (
<div className="h-full w-full">
<WorkspaceView workspace={workspace} />
</div>
);
}
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView workspace={workspace} initialFilePath="/Onboarding" defaultFileSort={defaultSort} components={components} ephemeral={ephemeral} /> <WorkspaceView
workspace={workspace}
locked
initialFilePath="/Onboarding"
defaultFileSort={defaultSort}
components={components}
ephemeral={ephemeral}
/>
</div> </div>
); );
}; };
@@ -1,7 +1,7 @@
import { Link } from 'react-router'; import { Link } from 'react-router';
import * as Dropdown from '@/components/ui/dropdown-menu'; import * as Dropdown from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { User, Users, LogOut, Settings, Package, Sun, Moon } from 'lucide-react'; import { User, Users, LogOut, Settings, Package, Puzzle, Sun, Moon } from 'lucide-react';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useTranslation } from '@/lib/i18n'; import { useTranslation } from '@/lib/i18n';
import { useColorMode } from '@/components/ui/ThemeProvider'; import { useColorMode } from '@/components/ui/ThemeProvider';
@@ -59,6 +59,12 @@ export function UserMenu() {
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/integrations">
<Puzzle className="mr-2 h-4 w-4" />
Integrations
</Link>
</DropdownMenuItem>
{user?.role === 'Super Admin' && ( {user?.role === 'Super Admin' && (
<DropdownMenuItem asChild className="cursor-pointer"> <DropdownMenuItem asChild className="cursor-pointer">
<Link to="/settings/users"> <Link to="/settings/users">
@@ -30,6 +30,7 @@ export const ProjectListScreen = () => {
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView <WorkspaceView
workspace={workspace} workspace={workspace}
locked
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => { onMobilePanelChange={(id) => {
if (!id) setSelected(null); if (!id) setSelected(null);
@@ -0,0 +1,109 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
type GoogleStatus = {
connected: boolean;
email: string | null;
configured: boolean;
};
export const GoogleAccount = () => {
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, configured: false });
const fetchStatus = () => {
client
.get<GoogleStatus>('/integrations/google/status')
.then(setStatus)
.catch(() => {})
.finally(() => setIsLoading(false));
};
useEffect(() => {
fetchStatus();
const params = new URLSearchParams(window.location.search);
const result = params.get('google');
if (result === 'success') {
toast.success('Google account connected');
} else if (result === 'error') {
toast.error('Failed to connect Google account');
}
if (result) {
window.history.replaceState({}, '', window.location.pathname);
}
}, []);
const handleConnect = () => {
const params = new URLSearchParams({
token: client.token ?? '',
origin: window.location.origin,
});
window.location.href = `/api/integrations/google/authorize?${params.toString()}`;
};
const handleDisconnect = async () => {
try {
await client.delete('/integrations/google/connection');
setStatus({ ...status, connected: false, email: null });
toast.success('Google account disconnected');
} catch {
toast.error('Failed to disconnect Google account');
}
};
if (isLoading) return null;
if (!status.configured) {
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Google integration has not been configured yet. Ask your administrator to set up Google OAuth credentials in
the Enterprise settings.
</p>
</div>
);
}
if (status.connected) {
return (
<div className="grid gap-4">
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
</div>
</div>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Officer has access to your Google Calendar, Gmail, and other enabled services.
</p>
<Button
type="button"
variant="outline"
onClick={handleDisconnect}
className="w-full h-11 cursor-pointer"
>
Disconnect
</Button>
</div>
);
}
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Connect your Google account to give Officer access to your Calendar, Gmail, and other Google services.
</p>
<Button
type="button"
onClick={handleConnect}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
>
Connect Google Account
</Button>
</div>
);
};
@@ -0,0 +1,288 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { ChevronDown } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useClient } from 'hooks/useClient';
type GoogleOAuthSettings = {
clientId: string;
clientSecret: string;
};
const SCOPES = [
{ scope: 'gmail.readonly', description: 'Read emails' },
{ scope: 'calendar.readonly', description: 'Read calendar events' },
];
const SetupGuide = ({ redirectUri }: { redirectUri: string }) => {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-duck-teal cursor-pointer hover:underline w-full">
<ChevronDown className={`h-3.5 w-3.5 transition-transform duration-200 ${open ? 'rotate-180' : ''}`} />
Step-by-step setup guide
</CollapsibleTrigger>
<CollapsibleContent>
<ol className="mt-3 grid gap-4 text-sm text-duck-dark/70 dark:text-foreground/70 list-decimal list-outside pl-5">
<li>
<strong className="text-duck-dark dark:text-foreground">Create a Google Cloud project</strong>
<p className="mt-1">
Go to the{' '}
<a href="https://console.cloud.google.com/projectcreate" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
New Project
</a>{' '}
page. Give it a name (e.g. &quot;Officer&quot;) and click <strong>Create</strong>.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Enable the APIs</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/apis/library" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
API Library
</a>
. Search for and enable each of these:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li><strong>Gmail API</strong></li>
<li><strong>Google Calendar API</strong></li>
</ul>
<p className="mt-1">Click each one, then click <strong>Enable</strong>.</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Configure the OAuth consent screen</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/branding" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
OAuth Branding
</a>
.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Set <strong>App name</strong> to your organization name or &quot;Officer&quot;</li>
<li>Set <strong>User support email</strong> to your admin email</li>
<li>Add your admin email under <strong>Developer contact information</strong></li>
<li>Click <strong>Save</strong></li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Set the audience</strong>
<p className="mt-1">
Go to{' '}
<a href="https://console.cloud.google.com/auth/audience" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
OAuth Audience
</a>
.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>
If your team uses Google Workspace, select <strong>Internal</strong> no verification needed
</li>
<li>
Otherwise, select <strong>External</strong> and add your team's emails under <strong>Test users</strong> (required while the app is unverified; limit of 100 test users)
</li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Add scopes</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/scopes" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
Data Access
</a>
, then click <strong>Add or remove scopes</strong>. Search for and add:
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
{SCOPES.map((s) => (
<li key={s.scope}>
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded">
{s.scope}
</code>{' '}
— {s.description}
</li>
))}
</ul>
<p className="mt-1">Click <strong>Update</strong>, then <strong>Save</strong>.</p>
<p className="mt-2 text-xs text-duck-dark/50 dark:text-foreground/50">
Note: <code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">calendar.readonly</code> is classified as <strong>sensitive</strong> and{' '}
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1 py-0.5 rounded">gmail.readonly</code> as <strong>restricted</strong> by Google.
This is fine for Internal apps (Google Workspace) and External apps in testing mode. Publishing to production with restricted scopes requires Google verification.
</p>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Create OAuth credentials</strong>
<p className="mt-1">
In the left sidebar, click{' '}
<a href="https://console.cloud.google.com/auth/clients" target="_blank" rel="noopener noreferrer" className="text-duck-teal underline">
Clients
</a>
, then click <strong>Create OAuth client</strong>.
</p>
<ul className="mt-1 list-disc list-outside pl-5 grid gap-0.5">
<li>Application type: <strong>Web application</strong></li>
<li>Name: anything (e.g. &quot;Officer&quot;)</li>
<li>
Authorized redirect URIs: add{' '}
<code className="text-xs bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded break-all">
{redirectUri}
</code>
</li>
<li>Click <strong>Create</strong></li>
</ul>
</li>
<li>
<strong className="text-duck-dark dark:text-foreground">Copy the credentials</strong>
<p className="mt-1">
A dialog will show your <strong>Client ID</strong> and <strong>Client Secret</strong>. Copy both and paste them into the fields below.
</p>
</li>
</ol>
</CollapsibleContent>
</Collapsible>
);
};
type VerifyStatus = { valid: boolean; error: string | null } | null;
const CredentialStatus = ({ status, isVerifying }: { status: VerifyStatus; isVerifying: boolean }) => {
if (isVerifying) {
return (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className="h-2.5 w-2.5 rounded-full bg-duck-dark/20 dark:bg-foreground/20 animate-pulse shrink-0" />
<span className="text-sm text-duck-dark/50 dark:text-foreground/50">Verifying credentials...</span>
</div>
);
}
if (!status) return null;
return (
<div className="flex items-center gap-2 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<div className={`h-2.5 w-2.5 rounded-full shrink-0 ${status.valid ? 'bg-green-500' : 'bg-red-500'}`} />
<span className={`text-sm ${status.valid ? 'text-duck-dark dark:text-foreground' : 'text-red-500'}`}>
{status.valid ? 'Credentials valid' : status.error ?? 'Invalid credentials'}
</span>
</div>
);
};
export const GoogleOAuthConfig = () => {
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [clientId, setClientId] = useState('');
const [clientSecret, setClientSecret] = useState('');
const [verifyStatus, setVerifyStatus] = useState<VerifyStatus>(null);
const [isVerifying, setIsVerifying] = useState(false);
const verify = () => {
setIsVerifying(true);
client
.get<{ valid: boolean; error: string | null }>('/integrations/google/verify')
.then(setVerifyStatus)
.catch(() => setVerifyStatus({ valid: false, error: 'Verification request failed' }))
.finally(() => setIsVerifying(false));
};
useEffect(() => {
client
.get<GoogleOAuthSettings | null>('/integrations/google/config')
.then((data) => {
if (data) {
setClientId(data.clientId);
setClientSecret(data.clientSecret);
}
})
.catch(() => {})
.finally(() => {
setIsLoading(false);
});
}, []);
// Verify on load if credentials exist
useEffect(() => {
if (!isLoading && clientId && clientSecret) verify();
}, [isLoading]);
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
await client.put('/integrations/google/config', { clientId: clientId.trim(), clientSecret: clientSecret.trim() });
toast.success('Google OAuth configuration saved');
verify();
} catch {
toast.error('Failed to save Google OAuth configuration');
} finally {
setIsSaving(false);
}
};
if (isLoading) return null;
const redirectUri = `${window.location.origin}/api/integrations/google/callback`;
return (
<div className="grid gap-5">
<CredentialStatus status={verifyStatus} isVerifying={isVerifying} />
<SetupGuide redirectUri={redirectUri} />
<div className="border-t border-duck-dark/10 dark:border-foreground/10 pt-5 grid gap-5">
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client ID</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="text"
value={clientId}
onChange={(ev) => setClientId(ev.target.value)}
placeholder="123456789.apps.googleusercontent.com"
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Client Secret</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="password"
value={clientSecret}
onChange={(ev) => setClientSecret(ev.target.value)}
placeholder="GOCSPX-..."
/>
</Label>
<Label className="grid gap-2">
<span className="text-duck-dark/70 dark:text-foreground/70">Redirect URI</span>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Add this URI to your Google OAuth client&apos;s authorized redirect URIs
</p>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark/60"
type="text"
value={redirectUri}
readOnly
/>
</Label>
<Button
type="button"
onClick={handleSave}
disabled={isSaving || !clientId.trim() || !clientSecret.trim()}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer disabled:opacity-50 disabled:hover:scale-100"
>
{isSaving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,90 @@
import { useMemo } from 'react';
import { Puzzle, KeyRound, UserCircle } from 'lucide-react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth';
import { useGlobal } from 'hooks/useGlobal';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SettingsSidebar, SettingsContent, type SettingsSection } from '../SettingsPanel';
import { GoogleOAuthConfig } from './GoogleOAuthConfig';
import { GoogleAccount } from './GoogleAccount';
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
const enterpriseSections: SettingsSection[] = [
{ key: 'google-oauth', icon: KeyRound, title: 'Google OAuth', description: 'Client ID and secret for Google APIs', content: <GoogleOAuthConfig /> },
];
const personalSections: SettingsSection[] = [
{ key: 'google-account', icon: UserCircle, title: 'Google Account', description: 'Connect your Google account', content: <GoogleAccount /> },
];
const IntegrationsSidebar = () => {
const { user } = useAuth();
const isSuperAdmin = user?.role === 'Super Admin';
const [tab, setTab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
return (
<div className="flex flex-col h-full">
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Puzzle className="h-4 w-4" />
Integrations
</div>
</div>
{isSuperAdmin && (
<div className="px-3 pb-2">
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="w-full">
<TabsTrigger value="enterprise" className="flex-1 cursor-pointer">
Enterprise
</TabsTrigger>
<TabsTrigger value="personal" className="flex-1 cursor-pointer">
Personal
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
<SettingsSidebar globalKey={GLOBAL_KEY} icon={Puzzle} label="Integrations" sections={sections} hideHeader />
</div>
);
};
const IntegrationsContent = () => {
const { user } = useAuth();
const isSuperAdmin = user?.role === 'Super Admin';
const [tab] = useGlobal<string>(TAB_KEY, isSuperAdmin ? 'enterprise' : 'personal');
const sections = tab === 'enterprise' ? enterpriseSections : personalSections;
return <SettingsContent globalKey={GLOBAL_KEY} sections={sections} />;
};
const layout: LayoutNode = {
type: 'group',
id: 'integrations-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'integrations-left', appType: null }, size: 20 },
{ node: { type: 'panel', id: 'integrations-right', appType: null }, size: 80 },
],
};
export const IntegrationsSettings = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'integrations-left': IntegrationsSidebar,
'integrations-right': IntegrationsContent,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} components={panelComponents} />
</div>
);
};
@@ -24,6 +24,7 @@ type SettingsSidebarProps = {
label: string; label: string;
sections: SettingsSection[]; sections: SettingsSection[];
groups?: SettingsSectionGroup[]; groups?: SettingsSectionGroup[];
hideHeader?: boolean;
}; };
const SectionButton = ({ const SectionButton = ({
@@ -50,7 +51,7 @@ const SectionButton = ({
</button> </button>
); );
export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups }: SettingsSidebarProps) => { export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups, hideHeader }: SettingsSidebarProps) => {
const allSections = groups ? groups.flatMap((g) => g.sections) : sections; const allSections = groups ? groups.flatMap((g) => g.sections) : sections;
const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, allSections[0]?.key ?? null); const [selectedKey, setSelectedKey] = useGlobal<string | null>(globalKey, allSections[0]?.key ?? null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -61,12 +62,14 @@ export const SettingsSidebar = ({ globalKey, icon: Icon, label, sections, groups
return ( return (
<div className="flex flex-col h-full overflow-y-auto"> <div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-2"> {!hideHeader && (
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"> <div className="p-3 pb-2">
<Icon className="h-4 w-4" /> <div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
{label} <Icon className="h-4 w-4" />
{label}
</div>
</div> </div>
</div> )}
<div className="px-3 pb-2"> <div className="px-3 pb-2">
<Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" /> <Input placeholder="Search..." value={search} onChange={(ev) => setSearch(ev.target.value)} className="h-8 text-xs" />
</div> </div>
@@ -2,3 +2,4 @@ export * from './ProfileSettings';
export * from './SystemSettings'; export * from './SystemSettings';
export * from './ResourceSettings'; export * from './ResourceSettings';
export * from './UserSettings'; export * from './UserSettings';
export * from './IntegrationsSettings';
@@ -8,7 +8,7 @@ export const TerminalScreen = () => {
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView workspace={workspace} /> <WorkspaceView workspace={workspace} locked />
</div> </div>
); );
}; };
@@ -15,6 +15,7 @@ export const WorkspacesScreen = () => {
<div className="h-full w-full"> <div className="h-full w-full">
<WorkspaceView <WorkspaceView
workspace={workspace} workspace={workspace}
locked
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => { onMobilePanelChange={(id) => {
if (!id) setSelected(null); if (!id) setSelected(null);
+5 -2
View File
@@ -14,8 +14,11 @@ dockRouter.get('/', async (ctx) => {
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (await file.exists()) { if (await file.exists()) {
const data = await file.json(); try {
return ctx.json(data); return ctx.json(await file.json());
} catch {
// corrupted file — treat as missing
}
} }
return ctx.json(null); return ctx.json(null);
@@ -0,0 +1,218 @@
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { createRouter } from '../../create-router';
import { DATA_PATH } from '@@/data-path';
import { CustomError } from '../../custom-errors';
const configDir = `${homedir()}/.config/officer.dev`;
const googleConfigPath = join(configDir, 'google-oauth.json');
const GOOGLE_SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/userinfo.email',
];
const ensureDir = (filePath: string) => mkdir(dirname(filePath), { recursive: true });
export const readGoogleConfig = async () => {
try {
return await Bun.file(googleConfigPath).json();
} catch {
return null;
}
};
const getUserGoogleFile = (email: string) => join(DATA_PATH, email, 'integrations', 'google.json');
const readUserGoogle = async (email: string) => {
try {
return await Bun.file(getUserGoogleFile(email)).json();
} catch {
return null;
}
};
const writeUserGoogle = async (email: string, data: Record<string, unknown>) => {
const filePath = getUserGoogleFile(email);
await ensureDir(filePath);
await Bun.write(filePath, JSON.stringify(data, null, 2));
};
export const integrationsRouter = createRouter();
integrationsRouter.get('/', async (ctx) => {
return ctx.json([]);
});
// --- Enterprise: Google OAuth config (Super Admin only) ---
integrationsRouter.get('/google/config', async (ctx) => {
const user = ctx.get('user');
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
return ctx.json(await readGoogleConfig());
});
integrationsRouter.put('/google/config', async (ctx) => {
const user = ctx.get('user');
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
const body = ctx.get('body') as { clientId?: string; clientSecret?: string };
const config = { clientId: body.clientId ?? '', clientSecret: body.clientSecret ?? '' };
await ensureDir(googleConfigPath);
await Bun.write(googleConfigPath, JSON.stringify(config, null, 2));
return ctx.json(config);
});
integrationsRouter.get('/google/verify', async (ctx) => {
const user = ctx.get('user');
if (user.role !== 'Super Admin') throw new CustomError('Forbidden', 403);
const config = await readGoogleConfig();
if (!config?.clientId || !config?.clientSecret) {
return ctx.json({ valid: false, error: 'Missing credentials' });
}
// Send a dummy token exchange — valid credentials return "invalid_grant",
// invalid credentials return "invalid_client"
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: config.clientId,
client_secret: config.clientSecret,
code: 'invalid_code',
redirect_uri: 'https://localhost',
grant_type: 'authorization_code',
}),
});
const body = await res.json();
const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch';
return ctx.json({ valid, error: valid ? null : body.error_description ?? body.error });
});
// --- Personal: Google account connection status ---
integrationsRouter.get('/google/status', async (ctx) => {
const email = ctx.get('user').email;
const config = await readGoogleConfig();
const connection = await readUserGoogle(email);
return ctx.json({
configured: !!(config?.clientId && config?.clientSecret),
connected: !!connection?.accessToken,
email: connection?.email ?? null,
});
});
integrationsRouter.delete('/google/connection', async (ctx) => {
const email = ctx.get('user').email;
const filePath = getUserGoogleFile(email);
const file = Bun.file(filePath);
if (await file.exists()) {
await Bun.write(filePath, '{}');
}
return ctx.json({ ok: true });
});
// --- OAuth flow: authorize (protected — user must be logged in) ---
integrationsRouter.get('/google/authorize', async (ctx) => {
const config = await readGoogleConfig();
if (!config?.clientId || !config?.clientSecret) {
throw new CustomError('Google OAuth not configured', 400);
}
const email = ctx.get('user').email;
const origin = ctx.req.query('origin');
if (!origin) throw new CustomError('Missing origin parameter', 400);
const redirectUri = `${origin}/api/integrations/google/callback`;
const state = Buffer.from(JSON.stringify({ email, redirectUri })).toString('base64url');
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: GOOGLE_SCOPES.join(' '),
access_type: 'offline',
prompt: 'consent',
state,
});
return ctx.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
});
// --- OAuth callback (public — called by Google, exported for hono.ts) ---
export const googleCallbackHandler = async (ctx: any) => {
const code = ctx.req.query('code');
const stateParam = ctx.req.query('state');
const error = ctx.req.query('error');
if (error || !code || !stateParam) {
return ctx.redirect('/settings/integrations?google=error');
}
let email: string;
let redirectUri: string;
try {
const parsed = JSON.parse(Buffer.from(stateParam, 'base64url').toString());
email = parsed.email;
redirectUri = parsed.redirectUri;
} catch {
return ctx.redirect('/settings/integrations?google=error');
}
const config = await readGoogleConfig();
if (!config?.clientId || !config?.clientSecret) {
return ctx.redirect('/settings/integrations?google=error');
}
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
});
if (!tokenResponse.ok) {
console.error('Google token exchange failed:', await tokenResponse.text());
return ctx.redirect('/settings/integrations?google=error');
}
const tokens = await tokenResponse.json();
// Fetch the user's Google email
const userinfoResponse = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
let googleEmail = email;
if (userinfoResponse.ok) {
const userinfo = await userinfoResponse.json();
googleEmail = userinfo.email ?? email;
}
await writeUserGoogle(email, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
email: googleEmail,
scope: tokens.scope,
});
return ctx.redirect('/settings/integrations?google=success');
};
+30 -2
View File
@@ -1,13 +1,33 @@
import { join, relative } from "path"; import { join, relative } from "path";
import { readdirSync, existsSync, mkdirSync } from "node:fs";
import type { Subprocess } from "bun"; import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types"; import type { PiEvent, MessageCost } from "./types";
import { readApiKeys } from "../server-settings/pi-mono"; import { readApiKeys } from "../server-settings/pi-mono";
import { PI_CONFIG_DIR } from "../../data-path"; import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir } from "../../data-path";
import { ensureDockerContainer } from "../terminal/websocket"; import { ensureDockerContainer } from "../terminal/websocket";
import { logger } from "./logger"; import { logger } from "./logger";
export type PiEventHandler = (event: PiEvent) => void; export type PiEventHandler = (event: PiEvent) => void;
function collectSkillFlags(email: string): string[] {
const flags: string[] = [];
const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)];
for (const dir of dirs) {
if (!existsSync(dir)) continue;
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillFile = join(dir, entry.name, 'SKILL.md');
if (existsSync(skillFile)) {
flags.push('--skill', join(dir, entry.name));
}
}
}
return flags;
}
type SandboxOptions = { type SandboxOptions = {
userId: number; userId: number;
username: string; username: string;
@@ -18,6 +38,7 @@ type SandboxOptions = {
export async function spawnPi( export async function spawnPi(
cwd: string, cwd: string,
model: string, model: string,
email: string,
onEvent: PiEventHandler, onEvent: PiEventHandler,
sandbox?: SandboxOptions, sandbox?: SandboxOptions,
): Promise<Subprocess> { ): Promise<Subprocess> {
@@ -59,9 +80,14 @@ export async function spawnPi(
logger.info('Spawned Pi in container', { containerId, model }); logger.info('Spawned Pi in container', { containerId, model });
} else { } else {
const storedKeys = await readApiKeys(); const storedKeys = await readApiKeys();
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes']; const skillFlags = collectSkillFlags(email);
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags];
if (model) args.push('--model', model); if (model) args.push('--model', model);
if (!existsSync(cwd)) {
mkdirSync(cwd, { recursive: true });
}
proc = Bun.spawn(args, { proc = Bun.spawn(args, {
cwd, cwd,
stdin: 'pipe', stdin: 'pipe',
@@ -69,6 +95,8 @@ export async function spawnPi(
stderr: 'pipe', stderr: 'pipe',
env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR }, env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR },
}); });
logger.info('Spawned Pi locally', { model, skills: skillFlags.filter((f) => f !== '--skill').length });
} }
// Read stdout JSON event stream (runs in background) // Read stdout JSON event stream (runs in background)
+20 -18
View File
@@ -36,23 +36,25 @@ type WSData = {
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
const resolveRoot = (email: string, root?: string) => { const resolveSandboxedCwd = (email: string, cwdRoot?: string, cwd?: string) => {
if (!root || root === 'home') return getHomeDir(email); const root = !cwdRoot || cwdRoot === 'home' ? getHomeDir(email) : getHomeDir(email);
if (root === '~') return homedir(); if (!cwd || cwd === '~') return root;
if (root === 'officer.dev') return resolve(process.cwd(), '..'); if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
return getHomeDir(email); if (cwd.startsWith('/')) return join(root, cwd.slice(1));
return root;
}; };
const resolveCwd = (home: string, cwd?: string) => { const resolveHostCwd = (cwdRoot?: string, cwd?: string) => {
if (!cwd || cwd === '~') return home; if (cwdRoot === 'officer.dev') return resolve(process.cwd(), '..');
if (cwd.startsWith('~/')) return join(home, cwd.slice(2)); const root = homedir();
if (cwd.startsWith('/')) return join(home, cwd.slice(1)); if (!cwd || cwd === '~') return root;
return home; if (cwd.startsWith('/')) return cwd;
if (cwd.startsWith('~/')) return join(root, cwd.slice(2));
return join(root, cwd);
}; };
export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => { export const resolveBaseCwd = (email: string, cwdRoot?: string, cwd?: string) => {
const root = resolveRoot(email, cwdRoot); return resolveHostCwd(cwdRoot, cwd);
return resolveCwd(root, cwd);
}; };
const wsToSessionMap = new WeakMap<any, string>(); const wsToSessionMap = new WeakMap<any, string>();
@@ -271,11 +273,11 @@ async function handleChat(
}); });
const homeDir = getHomeDir(email); const homeDir = getHomeDir(email);
const rootDir = resolveRoot(email, msg.cwdRoot);
const cwd = resolveCwd(rootDir, msg.cwd);
const groupSlug = msg.groupSlug || null;
const sandboxed = msg.sandboxed ?? false; const sandboxed = msg.sandboxed ?? false;
const cwd = sandboxed
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
: resolveHostCwd(msg.cwdRoot, msg.cwd);
const groupSlug = msg.groupSlug || null;
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug); const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug);
session.sandboxed = sandboxed; session.sandboxed = sandboxed;
session.userId = userId; session.userId = userId;
@@ -286,7 +288,7 @@ async function handleChat(
if (!session.piProcess) { if (!session.piProcess) {
try { try {
const onEvent = createEventHandler(sessionId, model, cwd, homeDir); const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined); session.piProcess = await piBridge.spawnPi(cwd, model, email, onEvent, sandboxed ? { userId, username, email, homeDir } : undefined);
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed }); logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
} catch (err) { } catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) }); logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
@@ -355,7 +357,7 @@ async function handleResume(
const homeDir = getHomeDir(email); const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined; const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir); const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox); session.piProcess = await piBridge.spawnPi(session.cwd, session.model, email, onEvent, sandbox);
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed }); logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
} catch (err) { } catch (err) {
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) }); logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
@@ -34,19 +34,22 @@ serverSettingsRouter.route('/tts', ttsRouter);
serverSettingsRouter.route('/stt', sttRouter); serverSettingsRouter.route('/stt', sttRouter);
serverSettingsRouter.route('/ocr', ocrRouter); serverSettingsRouter.route('/ocr', ocrRouter);
const readSettings = async () => {
try { return await Bun.file(settingsPath).json(); } catch { return {}; }
};
serverSettingsRouter.get('/settings', async (ctx) => { serverSettingsRouter.get('/settings', async (ctx) => {
const settings = await Bun.file(settingsPath).json(); return ctx.json(await readSettings());
return ctx.json(settings);
}); });
serverSettingsRouter.get('/onboarding-complete', async (ctx) => { serverSettingsRouter.get('/onboarding-complete', async (ctx) => {
const settings = await Bun.file(settingsPath).json(); const settings = await readSettings();
return ctx.json({ onboardingComplete: !!settings.onboardingComplete }); return ctx.json({ onboardingComplete: !!settings.onboardingComplete });
}); });
serverSettingsRouter.put('/', async (ctx) => { serverSettingsRouter.put('/', async (ctx) => {
const body = await ctx.req.json(); const body = await ctx.req.json();
const settings = await Bun.file(settingsPath).json(); const settings = await readSettings();
const updated = { ...settings, ...body }; const updated = { ...settings, ...body };
await Bun.write(settingsPath, JSON.stringify(updated, null, 2)); await Bun.write(settingsPath, JSON.stringify(updated, null, 2));
return ctx.json(updated); return ctx.json(updated);
+8 -5
View File
@@ -34,7 +34,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'claude') { if (provider === 'claude') {
const file = Bun.file(join(getSessionDir(email, id), 'messages.json')); const file = Bun.file(join(getSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]); if (!(await file.exists())) return ctx.json([]);
return ctx.json(await file.json()); try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
} }
if (provider === 'opencode') { if (provider === 'opencode') {
@@ -44,7 +44,7 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
if (provider === 'pi-mono') { if (provider === 'pi-mono') {
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json')); const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
if (!(await file.exists())) return ctx.json([]); if (!(await file.exists())) return ctx.json([]);
return ctx.json(await file.json()); try { return ctx.json(await file.json()); } catch { return ctx.json([]); }
} }
return ctx.json({ error: 'invalid provider' }, 400); return ctx.json({ error: 'invalid provider' }, 400);
@@ -78,7 +78,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getSessionDir(email, id); const dir = getSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json')); const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
const meta = await metaFile.json(); let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200); meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)); await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true }); return ctx.json({ ok: true });
@@ -88,7 +89,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getOpencodeSessionDir(email, id); const dir = getOpencodeSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json')); const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
const meta = await metaFile.json(); let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200); meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)); await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
@@ -106,7 +108,8 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
const dir = getPiMonoSessionDir(email, id); const dir = getPiMonoSessionDir(email, id);
const metaFile = Bun.file(join(dir, 'meta.json')); const metaFile = Bun.file(join(dir, 'meta.json'));
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404); if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
const meta = await metaFile.json(); let meta: Record<string, unknown>;
try { meta = await metaFile.json(); } catch { return ctx.json({ error: 'corrupted session' }, 500); }
meta.title = body.title.slice(0, 200); meta.title = body.title.slice(0, 200);
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)); await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
return ctx.json({ ok: true }); return ctx.json({ ok: true });
+11 -5
View File
@@ -27,8 +27,11 @@ settingsRouter.get('/settings', async (ctx) => {
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (await file.exists()) { if (await file.exists()) {
const data = await file.json(); try {
return ctx.json(data); return ctx.json(await file.json());
} catch {
// corrupted — fall through to defaults
}
} }
await ensureDir(filePath); await ensureDir(filePath);
@@ -54,8 +57,11 @@ settingsRouter.get('/state', async (ctx) => {
const file = Bun.file(filePath); const file = Bun.file(filePath);
if (await file.exists()) { if (await file.exists()) {
const data = await file.json(); try {
return ctx.json(data); return ctx.json(await file.json());
} catch {
// corrupted — fall through to empty
}
} }
await ensureDir(filePath); await ensureDir(filePath);
@@ -72,7 +78,7 @@ settingsRouter.patch('/state', async (ctx) => {
let existing: Record<string, unknown> = {}; let existing: Record<string, unknown> = {};
if (await file.exists()) { if (await file.exists()) {
existing = await file.json(); try { existing = await file.json(); } catch { /* corrupted — start fresh */ }
} }
const merged = { ...existing, ...body }; const merged = { ...existing, ...body };
+9 -4
View File
@@ -72,9 +72,13 @@ export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
} }
export async function readJsonFile(path: string): Promise<unknown | null> { export async function readJsonFile(path: string): Promise<unknown | null> {
const file = Bun.file(path); try {
if (await file.exists()) return file.json(); const file = Bun.file(path);
return null; if (!(await file.exists())) return null;
return await file.json();
} catch {
return null;
}
} }
export async function writeJsonFile(path: string, data: unknown) { export async function writeJsonFile(path: string, data: unknown) {
@@ -86,7 +90,8 @@ export async function migrateFromState(email: string, dirs: ResolveDirs) {
const file = Bun.file(stateFile); const file = Bun.file(stateFile);
if (!(await file.exists())) return; if (!(await file.exists())) return;
const state = (await file.json()) as Record<string, unknown>; let state: Record<string, unknown>;
try { state = (await file.json()) as Record<string, unknown>; } catch { return; }
const wsKeys = Object.keys(state).filter( const wsKeys = Object.keys(state).filter(
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'), (k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
); );
+2
View File
@@ -5,6 +5,7 @@ import { DATA_PATH, PI_CONFIG_DIR } from './data-path';
import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config'; import { syncLocalProvidersToPiConfig } from './api/server-settings/sync-pi-config';
import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config'; import { syncAllUserPiConfigs } from './api/server-settings/sync-user-pi-config';
import { initAuthStore } from 'officerdb'; import { initAuthStore } from 'officerdb';
import { syncSeedSkills } from './sync-skills';
mkdirSync(DATA_PATH, { recursive: true }); mkdirSync(DATA_PATH, { recursive: true });
mkdirSync(PI_CONFIG_DIR, { recursive: true }); mkdirSync(PI_CONFIG_DIR, { recursive: true });
@@ -69,6 +70,7 @@ function seedPiConfig(): void {
} }
seedPiConfig(); seedPiConfig();
syncSeedSkills();
await syncLocalProvidersToPiConfig().catch(err => { await syncLocalProvidersToPiConfig().catch(err => {
console.error('[bootstrap] Failed to sync local providers to Pi config:', err); console.error('[bootstrap] Failed to sync local providers to Pi config:', err);
+3
View File
@@ -20,6 +20,7 @@ import { router as fileBrowserRouter } from './api/file-browser/router';
import { piRestRouter } from './api/pi/rest'; import { piRestRouter } from './api/pi/rest';
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router'; import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
import { dockRouter } from './api/dock/dock'; import { dockRouter } from './api/dock/dock';
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
import { CustomError } from './custom-errors'; import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser } from './_middlewares'; import { userMiddleware, bodyParser } from './_middlewares';
@@ -42,6 +43,7 @@ honoServer.route('/api/auth', authRouter);
honoServer.route('/api/server-settings', serverSettingsRouter); honoServer.route('/api/server-settings', serverSettingsRouter);
honoServer.route('/api/landing-page-data', landingPageDataRouter); honoServer.route('/api/landing-page-data', landingPageDataRouter);
honoServer.route('/api/dev-server-proxy', devServerProxyRouter); honoServer.route('/api/dev-server-proxy', devServerProxyRouter);
honoServer.get('/api/integrations/google/callback', googleCallbackHandler);
const protectedRouter = createRouter(); const protectedRouter = createRouter();
protectedRouter.use(bodyParser()); protectedRouter.use(bodyParser());
@@ -61,6 +63,7 @@ protectedRouter.route('/task-logs', taskLogsRouter);
protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/file-browser', fileBrowserRouter);
protectedRouter.route('/dev-server', devServerRouter); protectedRouter.route('/dev-server', devServerRouter);
protectedRouter.route('/dock', dockRouter); protectedRouter.route('/dock', dockRouter);
protectedRouter.route('/integrations', integrationsRouter);
protectedRouter.route('/', piRestRouter); protectedRouter.route('/', piRestRouter);
honoServer.route('/api', protectedRouter); honoServer.route('/api', protectedRouter);
+32
View File
@@ -0,0 +1,32 @@
import { readdirSync, existsSync, mkdirSync, cpSync } from 'node:fs';
import { join } from 'node:path';
import { SEED_PATH, DATA_PATH } from './data-path';
const SEED_SKILLS_DIR = join(SEED_PATH, 'skills');
const GLOBAL_SKILLS_DIR = join(DATA_PATH, 'skills');
export function syncSeedSkills(): void {
if (!existsSync(SEED_SKILLS_DIR)) return;
mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
const seedEntries = readdirSync(SEED_SKILLS_DIR, { withFileTypes: true });
for (const entry of seedEntries) {
if (!entry.isDirectory()) continue;
const seedSkillDir = join(SEED_SKILLS_DIR, entry.name);
const skillFile = join(seedSkillDir, 'SKILL.md');
if (!existsSync(skillFile)) continue;
const targetDir = join(GLOBAL_SKILLS_DIR, entry.name);
if (existsSync(targetDir)) {
// Skill already exists in DATA_PATH — skip to preserve user edits
continue;
}
cpSync(seedSkillDir, targetDir, { recursive: true });
console.log(`[skills] Synced seed skill: ${entry.name}`);
}
}
@@ -27,6 +27,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
useEffect(() => { useEffect(() => {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
let blobUrl: string | null = null;
const onLoaded = () => { const onLoaded = () => {
setDuration(v.duration); setDuration(v.duration);
setLoaded(true); setLoaded(true);
@@ -35,13 +36,33 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
const onPlay = () => setPlaying(true); const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false); const onPause = () => setPlaying(false);
const onEnded = () => setPlaying(false); const onEnded = () => setPlaying(false);
let fetching = false;
let fetchDone = false;
const onError = () => { const onError = () => {
if (fallbackSrc && v.src !== fallbackSrc) { if (fetching) return;
v.src = fallbackSrc; if (fetchDone) {
v.load();
} else {
setError(true); setError(true);
return;
} }
fetching = true;
const fetchUrl = fallbackSrc || src;
fetch(fetchUrl)
.then((res) => {
if (!res.ok) throw new Error();
return res.blob();
})
.then((blob) => {
fetching = false;
fetchDone = true;
blobUrl = URL.createObjectURL(blob);
v.src = blobUrl;
v.load();
})
.catch(() => {
fetching = false;
fetchDone = true;
setError(true);
});
}; };
v.addEventListener('loadedmetadata', onLoaded); v.addEventListener('loadedmetadata', onLoaded);
v.addEventListener('timeupdate', onTime); v.addEventListener('timeupdate', onTime);
@@ -56,6 +77,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
v.removeEventListener('pause', onPause); v.removeEventListener('pause', onPause);
v.removeEventListener('ended', onEnded); v.removeEventListener('ended', onEnded);
v.removeEventListener('error', onError); v.removeEventListener('error', onError);
if (blobUrl) URL.revokeObjectURL(blobUrl);
}; };
}, []); }, []);
@@ -125,7 +147,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
if (playing) setShowControls(false); if (playing) setShowControls(false);
}} }}
> >
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} /> <video ref={videoRef} src={src} preload="metadata" playsInline className="max-w-full max-h-full" onClick={togglePlay} />
{loaded && !playing && ( {loaded && !playing && (
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer"> <button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
@@ -19,6 +19,7 @@ type PanelSlotProps = {
registry: AppRegistry; registry: AppRegistry;
components?: PanelComponents; components?: PanelComponents;
interactive: boolean; interactive: boolean;
locked: boolean;
noHeader: boolean; noHeader: boolean;
isLastPanel: boolean; isLastPanel: boolean;
onSetApp: (panelId: string, appType: string | null) => void; onSetApp: (panelId: string, appType: string | null) => void;
@@ -164,6 +165,46 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
); );
}; };
const MaximizeButton = ({ panelId }: { panelId: string }) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
return (
<div className="flex items-center gap-1.5 shrink-0 ml-auto">
<button
type="button"
onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}
className="group/btn h-3 w-3 rounded-full bg-[#28c840] hover:brightness-90 transition-all cursor-pointer flex items-center justify-center"
title={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? (
<Minus className="h-2 w-2 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity" strokeWidth={3} />
) : (
<svg viewBox="0 0 10 10" className="h-1.5 w-1.5 text-[#006500] opacity-0 group-hover/btn:opacity-100 transition-opacity">
<path d="M0 3.5L5 0L10 3.5V10H0Z" fill="currentColor" />
</svg>
)}
</button>
</div>
);
};
const MaximizeContextMenu = ({ panelId, children }: { panelId: string; children: React.ReactNode }) => {
const { maximizedPanelId, setMaximizedPanelId } = useWorkspace();
const isMaximized = maximizedPanelId === panelId;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setMaximizedPanelId(isMaximized ? null : panelId)}>
{isMaximized ? 'Restore' : 'Maximize'}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
// TODO: drag-to-reposition needs work (visual feedback, edge cases) // TODO: drag-to-reposition needs work (visual feedback, edge cases)
// const DragHandle = ({ panelId }: { panelId: string }) => { // const DragHandle = ({ panelId }: { panelId: string }) => {
// const { setDragSourceId, dragSourceId } = useWorkspace(); // const { setDragSourceId, dragSourceId } = useWorkspace();
@@ -182,7 +223,7 @@ const TrafficLights = ({ panelId, isLastPanel, onRemove, onClearApp }: { panelId
// return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />; // return <div className="absolute inset-0 z-20 rounded-lg bg-duck-dark/10 pointer-events-none" />;
// }; // };
export const PanelSlot = ({ panel, registry, components, interactive, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => { export const PanelSlot = ({ panel, registry, components, interactive, locked, noHeader, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace(); const { maximizedPanelId, transitioningPanelId, isMobile, onMobileBack } = useWorkspace();
const isMaximized = maximizedPanelId === panel.id; const isMaximized = maximizedPanelId === panel.id;
@@ -199,14 +240,18 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
const onClose = panelEntry?.onClose; const onClose = panelEntry?.onClose;
const contextMenu = interactive const contextMenu = interactive
? (content: React.ReactNode) => ( ? locked
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}> ? (content: React.ReactNode) => (
{content} <MaximizeContextMenu panelId={panel.id}>{content}</MaximizeContextMenu>
</PanelContextMenu> )
) : (content: React.ReactNode) => (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{content}
</PanelContextMenu>
)
: (content: React.ReactNode) => <>{content}</>; : (content: React.ReactNode) => <>{content}</>;
const overlays = interactive ? ( const overlays = interactive && !locked ? (
<> <>
<SwapOverlay panelId={panel.id} /> <SwapOverlay panelId={panel.id} />
<SwapSourceIndicator panelId={panel.id} /> <SwapSourceIndicator panelId={panel.id} />
@@ -214,7 +259,7 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
) : null; ) : null;
if (!AppComponent) { if (!AppComponent) {
if (!interactive) { if (!interactive || locked) {
return ( return (
<div className="h-full w-full p-1"> <div className="h-full w-full p-1">
<div className="h-full w-full rounded-lg border-3 border-duck-teal/50" /> <div className="h-full w-full rounded-lg border-3 border-duck-teal/50" />
@@ -254,7 +299,11 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
const ResolvedHeader = HeaderComponent ?? DefaultHeader; const ResolvedHeader = HeaderComponent ?? DefaultHeader;
const trafficLights = interactive && !isMobile ? ( const trafficLights = interactive && !isMobile ? (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} /> locked ? (
<MaximizeButton panelId={panel.id} />
) : (
<TrafficLights panelId={panel.id} isLastPanel={isLastPanel} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)} />
)
) : null; ) : null;
const mobileBackButton = isMobile && onMobileBack ? ( const mobileBackButton = isMobile && onMobileBack ? (
@@ -285,9 +334,13 @@ export const PanelSlot = ({ panel, registry, components, interactive, noHeader,
); );
const headerBar = interactive ? ( const headerBar = interactive ? (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}> locked ? (
{headerContent} <MaximizeContextMenu panelId={panel.id}>{headerContent}</MaximizeContextMenu>
</PanelContextMenu> ) : (
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
{headerContent}
</PanelContextMenu>
)
) : headerContent; ) : headerContent;
const body = ( const body = (
@@ -9,6 +9,7 @@ type WorkspaceRendererProps = {
registry: AppRegistry; registry: AppRegistry;
components?: PanelComponents; components?: PanelComponents;
interactive?: boolean; interactive?: boolean;
locked?: boolean;
noHeader?: boolean; noHeader?: boolean;
isMobile?: boolean; isMobile?: boolean;
mobilePanelId?: string; mobilePanelId?: string;
@@ -23,6 +24,7 @@ export const WorkspaceRenderer = ({
registry, registry,
components, components,
interactive = false, interactive = false,
locked = false,
noHeader = false, noHeader = false,
isMobile = false, isMobile = false,
mobilePanelId, mobilePanelId,
@@ -40,6 +42,7 @@ export const WorkspaceRenderer = ({
registry={registry} registry={registry}
components={components} components={components}
interactive={interactive} interactive={interactive}
locked={locked}
noHeader={noHeader} noHeader={noHeader}
isMobile={isMobile} isMobile={isMobile}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
@@ -58,6 +61,7 @@ type LayoutNodeRendererProps = {
registry: AppRegistry; registry: AppRegistry;
components?: PanelComponents; components?: PanelComponents;
interactive: boolean; interactive: boolean;
locked: boolean;
noHeader: boolean; noHeader: boolean;
isMobile: boolean; isMobile: boolean;
mobilePanelId?: string; mobilePanelId?: string;
@@ -91,6 +95,7 @@ const LayoutNodeRenderer = ({
registry, registry,
components, components,
interactive, interactive,
locked,
noHeader, noHeader,
isMobile, isMobile,
mobilePanelId, mobilePanelId,
@@ -125,6 +130,7 @@ const LayoutNodeRenderer = ({
registry={registry} registry={registry}
components={components} components={components}
interactive={interactive} interactive={interactive}
locked={locked}
noHeader={noHeader} noHeader={noHeader}
isLastPanel={totalPanels <= 1} isLastPanel={totalPanels <= 1}
onSetApp={onSetApp} onSetApp={onSetApp}
@@ -144,6 +150,7 @@ const LayoutNodeRenderer = ({
registry={registry} registry={registry}
components={components} components={components}
interactive={interactive} interactive={interactive}
locked={locked}
noHeader={noHeader} noHeader={noHeader}
isMobile={isMobile} isMobile={isMobile}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
@@ -171,6 +178,7 @@ const LayoutNodeRenderer = ({
registry={registry} registry={registry}
components={components} components={components}
interactive={interactive} interactive={interactive}
locked={locked}
noHeader={noHeader} noHeader={noHeader}
isMobile={isMobile} isMobile={isMobile}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
@@ -198,6 +206,7 @@ const LayoutNodeRenderer = ({
registry={registry} registry={registry}
components={components} components={components}
interactive={interactive} interactive={interactive}
locked={locked}
noHeader={noHeader} noHeader={noHeader}
isMobile={isMobile} isMobile={isMobile}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
@@ -12,6 +12,7 @@ import { useAppRegistry } from '../../AppRegistry/useAppRegistry';
type WorkspaceViewProps = { type WorkspaceViewProps = {
workspace: WorkspaceState; workspace: WorkspaceState;
locked?: boolean;
cwd?: string; cwd?: string;
root?: string; root?: string;
initialFilePath?: string; initialFilePath?: string;
@@ -24,7 +25,7 @@ type WorkspaceViewProps = {
const noop = () => {}; const noop = () => {};
export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => { export const WorkspaceView = ({ workspace, locked, cwd = '~', root, initialFilePath, defaultFileSort, components, ephemeral, mobilePanelId, onMobilePanelChange }: WorkspaceViewProps) => {
const { registry } = useAppRegistry(); const { registry } = useAppRegistry();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -178,11 +179,12 @@ export const WorkspaceView = ({ workspace, cwd = '~', root, initialFilePath, def
registry={registry} registry={registry}
components={components} components={components}
interactive interactive
locked={locked}
isMobile={isMobile} isMobile={isMobile}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
onSetApp={handleSetApp} onSetApp={locked ? noop : handleSetApp}
onSplit={handleSplit} onSplit={locked ? noop : handleSplit}
onRemove={handleRemove} onRemove={locked ? noop : handleRemove}
onResized={handleResized} onResized={handleResized}
/> />
</ResizablePanel> </ResizablePanel>
+7
View File
@@ -15,6 +15,7 @@ const mergeWithDefaults = (saved: Partial<UserSettings>): UserSettings => ({
tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks }, tasks: { ...DEFAULT_SETTINGS.tasks, ...saved.tasks },
appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance }, appearance: { ...DEFAULT_SETTINGS.appearance, ...saved.appearance },
languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages }, languages: { ...DEFAULT_SETTINGS.languages, ...saved.languages },
onboarding: { ...DEFAULT_SETTINGS.onboarding, ...saved.onboarding },
}); });
export const useSettings = () => { export const useSettings = () => {
@@ -72,6 +73,9 @@ export type UserSettings = {
default: string; default: string;
translateTo: string; translateTo: string;
}; };
onboarding: {
complete: boolean;
};
}; };
export type UserState = Record<string, unknown>; export type UserState = Record<string, unknown>;
@@ -103,4 +107,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
default: 'en', default: 'en',
translateTo: 'en', translateTo: 'en',
}, },
onboarding: {
complete: false,
},
}; };