extracted chat component to widgets

This commit is contained in:
2026-02-17 19:52:41 +00:00
parent 21213c281d
commit 74e98e95e1
37 changed files with 44 additions and 327 deletions
+1
View File
@@ -40,6 +40,7 @@ export function App() {
<Route path="/settings/ai" element={<Dashboard.AISettings />} />
<Route path="/settings/server" element={<Dashboard.ServerSettings />} />
<Route path="/settings/resources" element={<Dashboard.ResourceSettings />} />
<Route path="/chat" element={<Dashboard.SessionList />} />
<Route path="/chat/new" element={<Dashboard.NewChat />} />
<Route path="/chat/:sessionId" element={<Dashboard.ClaudeChat />} />
<Route path="/chat/opencode/new" element={<Dashboard.OpenCodeChat />} />
@@ -1,13 +1,10 @@
import { useRef, useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { useSessions } from './state/useSessions';
import { useSessions, useOpenCodeSessions, useSlashCommands, SessionBar } from 'widgets/ChatHistory';
import type { ModelOption } from '@/state/useModels';
import { useOpenCodeSessions } from './state/useOpenCodeSessions';
import type { useClaude, Attachment } from 'plugins/Chat/client';
import { EmbeddableChat } from 'plugins/Chat/client';
import { useSlashCommands } from './state/useSlashCommands';
import type { useClaude } from './useClaude';
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
import { Card } from '@/components/Card';
import { SessionBar } from './SessionBar';
export type { Attachment };
@@ -4,7 +4,7 @@ import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import type { ModelOption } from '@/state/useModels';
import type { useClaude } from './useClaude';
import { MessageList } from './MessageList';
import { MessageList } from 'widgets/Chat';
import { InputArea } from './InputArea';
export type Attachment =
@@ -10,7 +10,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from './types';
import type { ChatMessage } from 'widgets/Chat';
import type { Attachment } from './EmbeddableChat';
import { Settings } from './Settings';
@@ -1,7 +1,7 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useAuth } from 'hooks/useAuth';
import type { ModelOption } from '@/state/useModels';
import type { ChatMessage } from './types';
import type { ChatMessage } from 'widgets/Chat';
import { OpenCodeModelPicker } from './OpenCodeModelPicker';
type SettingsProps = {
@@ -1,8 +1,10 @@
import { useState } from 'react';
import { useParams } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import { useClaude, useOpenCode, type SessionEntry } from 'plugins/Chat/client';
import { ChatPanel } from 'plugins/ChatHistory/client';
import type { SessionEntry } from 'widgets/Chat';
import { useClaude } from './useClaude';
import { useOpenCode } from './useOpenCode';
import { ChatPanel } from './ChatPanel';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
export const ClaudeChat = () => {
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useSessions } from 'plugins/ChatHistory/client';
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
import { useSessions } from 'widgets/ChatHistory';
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
const SAVE_DEBOUNCE_MS = 1000;
@@ -2,8 +2,8 @@ import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useSettings } from '@/state/useSettings';
import { useVisibleOpenCodeModels } from '@/state/useModels';
import { useOpenCodeSessions } from 'plugins/ChatHistory/client';
import type { ChatMessage, ServerMessage, TaskInfo } from './types';
import { useOpenCodeSessions } from 'widgets/ChatHistory';
import type { ChatMessage, ServerMessage, TaskInfo } from 'widgets/Chat';
type UseOpenCodeOptions = {
replaceUrl?: boolean;
@@ -3,8 +3,7 @@ import { Link } from 'react-router';
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useSessions } from './state/useSessions';
import { useOpenCodeSessions } from './state/useOpenCodeSessions';
import { useSessions, useOpenCodeSessions } from 'widgets/ChatHistory';
type Filter = 'all' | 'claude' | 'opencode';
@@ -2,8 +2,8 @@ import { useMemo } from 'react';
import { Link } from 'react-router';
import { MessageSquare, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/Card';
import { useSessions } from './state/useSessions';
import { useOpenCodeSessions } from './state/useOpenCodeSessions';
import { useSessions } from 'widgets/ChatHistory';
import { useOpenCodeSessions } from 'widgets/ChatHistory';
import { useUserState } from '@/state/useUserState';
export const ChatHistory = () => {
@@ -0,0 +1,2 @@
export { ChatHistory as ChatHistoryWidget } from './Widget';
export { SessionList } from './Screen';
@@ -3,7 +3,10 @@ import { X } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import { useClaude, useOpenCode, EmbeddableChat, type TaskInfo } from 'plugins/Chat/client';
import type { TaskInfo } from 'widgets/Chat';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
import { useSettings } from '@/state/useSettings';
import type { TaskSummary } from 'widgets/FileBrowser';
@@ -26,7 +26,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { useSettings } from '@/state/useSettings';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
import type { Attachment } from 'plugins/Chat/client';
import type { Attachment } from '@/Screens/Dashboard/Chat/EmbeddableChat';
export const ChatLauncher = () => {
const navigate = useNavigate();
@@ -1,6 +1,6 @@
import { ChatLauncher } from './ChatLauncher';
import { FileBrowserWidget as FileBrowser } from '@/Screens/Dashboard/Files';
import { Widget as ChatHistory } from 'plugins/ChatHistory/client';
import { ChatHistoryWidget as ChatHistory } from '@/Screens/Dashboard/ChatHistory';
import { Catalog } from 'sounds';
export const HomeScreen = () => {
@@ -10,7 +10,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { useClient } from 'hooks/useClient';
import { useVisibleClaudeModels } from '@/state/useModels';
import { Card } from '@/components/Card';
import { useClaude, EmbeddableChat, type ChatMessage } from 'plugins/Chat/client';
import type { ChatMessage } from 'widgets/Chat';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
type ResourceSummary = {
dirName: string;
name: string;
@@ -5,7 +5,9 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/Card';
import { useClient } from 'hooks/useClient';
import { EmbeddableChat, useClaude, useOpenCode } from 'plugins/Chat/client';
import { useClaude } from '@/Screens/Dashboard/Chat/useClaude';
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
type AppStatus = {
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
import { MessageBubble, type ChatMessage } from 'plugins/Chat/client';
import { MessageBubble, type ChatMessage } from 'widgets/Chat';
type LogMetadata = {
filename: string;
@@ -12,3 +12,4 @@ export * from './TaskLogs';
export * from './Tasks';
export * from './Terminal';
export * from './Files';
export * from './ChatHistory';
+1 -1
View File
@@ -1,4 +1,4 @@
import { useSessions } from 'plugins/ChatHistory/client';
import { useSessions } from 'widgets/ChatHistory';
import { usePlans } from './usePlans';
import { useSettings } from './useSettings';
import { useThemeSync } from './useThemeSync';
-7
View File
@@ -1,7 +0,0 @@
export * from './client';
export const plugin = {
id: 'Chat',
name: 'Chat',
description: 'Embeddable chat UI',
};
@@ -1,7 +0,0 @@
export { ChatHistory as Widget } from './Widget';
export { SessionList as Screen } from './Screen';
export { ChatPanel } from './ChatPanel';
export { SessionBar } from './SessionBar';
export { useSessions } from './state/useSessions';
export { useOpenCodeSessions } from './state/useOpenCodeSessions';
export { useSlashCommands, type SlashCommandResult } from './state/useSlashCommands';
@@ -1,7 +0,0 @@
export { Widget, Screen, ChatPanel, SessionBar } from './client';
export const plugin = {
id: 'ChatHistory',
name: 'Chat History',
description: 'Session management for chat history',
};
-259
View File
@@ -1,259 +0,0 @@
# Building a Plugin
Plugins are self-contained modules that extend Officer with new features. They live under `src/workspaces/plugins/` and are auto-discovered at startup — no registration step required.
## Directory Structure
```
src/workspaces/plugins/
├── package.json # Workspace exports (update when adding a plugin)
└── MyPlugin/
├── index.ts # Metadata + re-exports
├── server/
│ ├── index.ts # Exports router and apiPath
│ └── router.ts # Hono API routes
└── client/
├── index.ts # Exports Widget and/or Screen
├── Widget.tsx # Compact component (dashboard home)
├── Screen/
│ └── index.tsx # Full-page component (dedicated route)
└── state/
└── useMyPlugin.ts # API client hook
```
A plugin can be server-only, client-only, or both. The discovery system checks for the presence of `server/index.ts` and `client/index.ts` to determine what the plugin provides.
## Step-by-Step
### 1. Create the plugin directory
```
mkdir -p src/workspaces/plugins/MyPlugin/{server,client/state,client/Screen}
```
### 2. Plugin metadata — `MyPlugin/index.ts`
Every plugin must export a `plugin` object with metadata. This is read by the settings UI and the `/plugins` API.
```ts
export { Widget, Screen } from './client';
export { router, apiPath } from './server';
export const plugin = {
id: 'MyPlugin', // Must match directory name
name: 'My Plugin', // Display name in settings
description: 'What this plugin does',
};
```
If your plugin is server-only, omit the client export. If client-only, omit the server export.
### 3. Server router — `MyPlugin/server/`
**`server/index.ts`** — Exports the router instance and the API path prefix:
```ts
export * from './router';
export const apiPath = 'my-plugin';
```
The `apiPath` determines the URL prefix. This router gets mounted at `/api/my-plugin`.
**`server/router.ts`** — Define your API endpoints:
```ts
import { createRouter } from '@@/create-router';
import * as errors from '@@/custom-errors';
export const router = createRouter();
router.get('/items', async (ctx) => {
const user = ctx.get('user'); // Authenticated user
// ...
return ctx.json({ items: [] });
});
router.post('/items', async (ctx) => {
const body = ctx.get('body'); // Parsed request body
if (!body.name) throw errors.BAD_REQUEST('Name is required');
// ...
return ctx.json({ created: true });
});
```
Key points:
- `createRouter()` from `@@/create-router` gives you a typed Hono router
- All plugin routes are **protected** — user authentication is enforced automatically
- Access the authenticated user with `ctx.get('user')` (returns `User` from types)
- Access parsed body with `ctx.get('body')`
- Throw `CustomError` instances for error responses — they're caught by the global error handler
**Available error helpers** (`@@/custom-errors`):
- `BAD_REQUEST(msg?)` — 400
- `UNAUTHORIZED(msg?)` — 401
- `FORBIDDEN(msg?)` — 403
- `NOT_FOUND(msg?)` — 404
- `CONFLICT(msg?)` — 409
- `INTERNAL_SERVER_ERROR(msg?)` — 500
- `TOO_MANY_REQUESTS(msg?, retryAfter?)` — 429
**User data helpers** (`@@/data-path`):
- `DATA_PATH` — base data directory
- `getHomeDir(email)` — user's home directory
### 4. Client components — `MyPlugin/client/`
**`client/index.ts`** — Export your components with standardized names:
```ts
export { MyPluginWidget as Widget } from './Widget';
export { MyPluginScreen as Screen } from './Screen';
```
**`client/state/useMyPlugin.ts`** — API client hook:
```ts
import { useClient } from 'hooks/useClient';
type Item = {
id: string;
name: string;
};
export const useMyPlugin = () => {
const client = useClient();
return {
listItems: () => client.get<Item[]>('/my-plugin/items'),
createItem: (name: string) => client.post('/my-plugin/items', { name }),
};
};
```
The `useClient()` hook provides an authenticated HTTP client. The path must match your `apiPath` from the server.
**`client/Widget.tsx`** — Compact component for the dashboard home:
```tsx
import { useState, useEffect } from 'react';
import { useMyPlugin } from './state/useMyPlugin';
export const MyPluginWidget = () => {
// Widget implementation
};
```
**`client/Screen/index.tsx`** — Full-page component:
```tsx
import { DashboardLayout } from '@/Screens/Dashboard/Layout';
export const MyPluginScreen = () => {
return (
<DashboardLayout>
{/* Screen implementation */}
</DashboardLayout>
);
};
```
### 5. Register workspace exports — `plugins/package.json`
Add subpath exports so the monorepo can import your plugin:
```json
{
"exports": {
"./FileBrowser": "./FileBrowser/index.ts",
"./FileBrowser/client": "./FileBrowser/client/index.ts",
"./FileBrowser/server": "./FileBrowser/server/index.ts",
"./MyPlugin": "./MyPlugin/index.ts",
"./MyPlugin/client": "./MyPlugin/client/index.ts",
"./MyPlugin/server": "./MyPlugin/server/index.ts"
}
}
```
This lets other code import your plugin as:
```ts
import { Widget, Screen } from 'plugins/MyPlugin/client';
import { router, apiPath } from 'plugins/MyPlugin/server';
import { plugin } from 'plugins/MyPlugin';
```
### 6. Wire into the app (optional)
The server router is auto-discovered and mounted — no changes needed. But if your plugin has client components that should appear in the main app, you'll need to add them manually:
**Route**`src/apps/officer-web/App.tsx`:
```tsx
import { Screen as MyPluginScreen } from 'plugins/MyPlugin/client';
// Inside the authenticated routes:
{plugins?.MyPlugin !== false && <Route path="/my-plugin" element={<MyPluginScreen />} />}
```
**Nav item**`src/apps/officer-web/Screens/Dashboard/Layout.tsx`:
```tsx
{plugins?.MyPlugin !== false && (
<DropdownMenuItem asChild className="cursor-pointer">
<Link to="/my-plugin">
<SomeIcon className="mr-2 h-4 w-4" />
My Plugin
</Link>
</DropdownMenuItem>
)}
```
**Home widget**`src/apps/officer-web/Screens/Dashboard/Home/index.tsx`:
```tsx
import { Widget as MyPluginWidget } from 'plugins/MyPlugin/client';
// Inside the layout:
{plugins?.MyPlugin !== false && <MyPluginWidget />}
```
The `plugins` object comes from `useServerSettings()` and is `undefined` when no overrides exist — so `plugins?.MyPlugin !== false` defaults to showing the plugin (opt-out model).
## Enable/Disable
Plugins are **enabled by default**. Admins can disable them in Server Settings > Plugins, which writes to `~/.config/officer.dev/server-settings.json`:
```json
{
"plugins": {
"MyPlugin": false
}
}
```
When disabled:
- **Server-side**: The plugin router is not mounted (requires server restart)
- **Client-side**: Routes, nav items, and widgets are hidden immediately
## Import Aliases
| Alias | Resolves to | Use in |
|-------|-------------|--------|
| `@@/` | `src/servers/` | Server code (`createRouter`, `custom-errors`, `data-path`) |
| `@/` | `src/apps/officer-web/` | Client code (`components/ui/*`, `Screens/*`, `state/*`) |
| `hooks/` | `src/workspaces/hooks/src/` | Both (`useClient`, `useAuth`) |
| `types` | `src/workspaces/types/` | Both |
| `config` | `src/workspaces/config/` | Client code |
| `plugins/` | `src/workspaces/plugins/` | Both |
## Checklist
- [ ] `MyPlugin/index.ts` exports `plugin` metadata with `id` matching directory name
- [ ] `MyPlugin/server/index.ts` exports `router` and `apiPath`
- [ ] `MyPlugin/client/index.ts` exports `Widget` and/or `Screen`
- [ ] `plugins/package.json` has subpath exports for the new plugin
- [ ] App routes gated with `plugins?.MyPlugin !== false`
- [ ] Nav items gated with `plugins?.MyPlugin !== false`
- [ ] Home widgets gated with `plugins?.MyPlugin !== false`
- [ ] `bunx tsgo` — no new type errors
-12
View File
@@ -1,12 +0,0 @@
{
"name": "plugins",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
"./Chat": "./Chat/index.ts",
"./Chat/client": "./Chat/client/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./ChatHistory/client": "./ChatHistory/client/index.ts"
}
}
@@ -1,11 +1,5 @@
export { EmbeddableChat, type Attachment } from './EmbeddableChat';
export { MessageList } from './MessageList';
export { InputArea } from './InputArea';
export { Settings } from './Settings';
export { MessageBubble, StreamingBubble } from './MessageBubble';
export { ToolActivity } from './ToolActivity';
export { QuestionActivity } from './QuestionActivity';
export { OpenCodeModelPicker } from './OpenCodeModelPicker';
export { useClaude } from './useClaude';
export { useOpenCode } from './useOpenCode';
export type { ChatMessage, SessionEntry, ServerMessage, TaskInfo } from './types';
@@ -0,0 +1,4 @@
export { SessionBar } from './SessionBar';
export { useSessions } from './useSessions';
export { useOpenCodeSessions } from './useOpenCodeSessions';
export { useSlashCommands, type SlashCommandResult } from './useSlashCommands';
@@ -1,4 +1,4 @@
import type { SessionEntry, ChatMessage } from 'plugins/Chat/client';
import type { SessionEntry, ChatMessage } from 'widgets/Chat';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -1,4 +1,4 @@
import type { SessionEntry, ChatMessage } from 'plugins/Chat/client';
import type { SessionEntry, ChatMessage } from 'widgets/Chat';
import type { SlashCommandResult } from './useSlashCommands';
import { useAuth } from 'hooks/useAuth';
import { useClient } from 'hooks/useClient';
+3 -1
View File
@@ -3,6 +3,8 @@
"private": true,
"exports": {
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts"
"./FileBrowser": "./FileBrowser/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
"./Chat": "./Chat/index.ts"
}
}