Workspaces and Terminals

This commit is contained in:
2026-02-19 18:01:08 +00:00
parent 9870fa7ae8
commit 4733a972dd
14 changed files with 402 additions and 57 deletions
@@ -23,6 +23,7 @@ type FileGridProps = {
onRenamingChange: (name: string | null) => void; onRenamingChange: (name: string | null) => void;
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[]; getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void; onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
scrollRef: React.RefObject<HTMLDivElement | null>; scrollRef: React.RefObject<HTMLDivElement | null>;
}; };
@@ -72,6 +73,7 @@ export const FileGrid = ({
onRenamingChange, onRenamingChange,
getMatchingTasks, getMatchingTasks,
onRunTask, onRunTask,
onCreateWorkspace,
scrollRef, scrollRef,
}: FileGridProps) => { }: FileGridProps) => {
const lastClickedIdx = useRef<number>(-1); const lastClickedIdx = useRef<number>(-1);
@@ -153,6 +155,7 @@ export const FileGrid = ({
onRenamingChange={onRenamingChange} onRenamingChange={onRenamingChange}
matchingTasks={getMatchingTasks(entry.name, entry.type)} matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={onRunTask} onRunTask={onRunTask}
onCreateWorkspace={onCreateWorkspace}
/> />
); );
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } from 'lucide-react'; import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid } from 'lucide-react';
import { getIcon } from 'material-file-icons'; import { getIcon } from 'material-file-icons';
import { import {
DropdownMenu, DropdownMenu,
@@ -43,6 +43,7 @@ export type FileItemProps = {
onRenamingChange: (name: string | null) => void; onRenamingChange: (name: string | null) => void;
matchingTasks: TaskSummary[]; matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void; onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
}; };
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
@@ -70,6 +71,7 @@ type MenuItemsProps = {
onCopy: () => void; onCopy: () => void;
matchingTasks: TaskSummary[]; matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void; onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (e: DirEntry) => void;
}; };
const DropdownMenuItems = ({ const DropdownMenuItems = ({
@@ -83,6 +85,7 @@ const DropdownMenuItems = ({
onCopy, onCopy,
matchingTasks, matchingTasks,
onRunTask, onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => ( }: MenuItemsProps) => (
<> <>
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer"> <DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
@@ -108,6 +111,15 @@ const DropdownMenuItems = ({
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
)} )}
{entry.type === 'directory' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onCut} className="cursor-pointer"> <DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" /> <Scissors className="mr-2 h-4 w-4" />
@@ -142,6 +154,7 @@ const ContextMenuItems = ({
onCopy, onCopy,
matchingTasks, matchingTasks,
onRunTask, onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => ( }: MenuItemsProps) => (
<> <>
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer"> <ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
@@ -167,6 +180,15 @@ const ContextMenuItems = ({
</ContextMenuSubContent> </ContextMenuSubContent>
</ContextMenuSub> </ContextMenuSub>
)} )}
{entry.type === 'directory' && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
</>
)}
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onClick={onCut} className="cursor-pointer"> <ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" /> <Scissors className="mr-2 h-4 w-4" />
@@ -300,6 +322,7 @@ export const FileItem = ({
onRenamingChange, onRenamingChange,
matchingTasks, matchingTasks,
onRunTask, onRunTask,
onCreateWorkspace,
}: FileItemProps) => { }: FileItemProps) => {
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -376,6 +399,7 @@ export const FileItem = ({
onCopy, onCopy,
matchingTasks, matchingTasks,
onRunTask, onRunTask,
onCreateWorkspace,
}; };
const cutOpacity = isCut ? 'opacity-50' : ''; const cutOpacity = isCut ? 'opacity-50' : '';
@@ -33,7 +33,11 @@ type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
type HomeRoot = 'home' | '~' | 'officer.dev'; type HomeRoot = 'home' | '~' | 'officer.dev';
export const Files = () => { type FilesProps = {
basePath?: string;
};
export const Files = ({ basePath = '/' }: FilesProps) => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -86,6 +90,10 @@ export const Files = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (basePath !== '/' && !currentPath.startsWith(basePath)) {
setCurrentPath(basePath);
return;
}
refresh(); refresh();
}, [currentPath, homeRoot]); }, [currentPath, homeRoot]);
@@ -137,6 +145,10 @@ export const Files = () => {
const selectedPaths = () => Array.from(selected).map(entryPath); const selectedPaths = () => Array.from(selected).map(entryPath);
const handleNavigate = (path: string) => { const handleNavigate = (path: string) => {
if (basePath !== '/' && !path.startsWith(basePath)) {
setCurrentPath(basePath);
return;
}
setCurrentPath(path); setCurrentPath(path);
}; };
@@ -262,6 +274,18 @@ export const Files = () => {
setRunningTask({ task, entry }); setRunningTask({ task, entry });
}; };
const handleCreateWorkspace = (entry: DirEntry) => {
const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
navigate(`/workspaces/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
};
const handleCreateWorkspaceHere = () => {
const dirName = currentPath === '/' ? '' : currentPath.split('/').pop()!;
const params = new URLSearchParams({ cwd: currentPath });
if (dirName) params.set('name', dirName);
navigate(`/workspaces/new?${params}`);
};
const handleGitClone = async () => { const handleGitClone = async () => {
const url = cloneUrl.trim(); const url = cloneUrl.trim();
if (!url) return; if (!url) return;
@@ -509,7 +533,7 @@ export const Files = () => {
</div> </div>
{/* Home dir selector (Super Admin only) */} {/* Home dir selector (Super Admin only) */}
{user?.role === 'Super Admin' && ( {basePath === '/' && user?.role === 'Super Admin' && (
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2"> <div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<RadioGroup <RadioGroup
value={homeRoot} value={homeRoot}
@@ -539,7 +563,7 @@ export const Files = () => {
{/* Breadcrumb */} {/* Breadcrumb */}
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2"> <div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<Breadcrumb path={currentPath} onNavigate={handleNavigate} /> <Breadcrumb path={currentPath} onNavigate={handleNavigate} basePath={basePath} />
</div> </div>
{/* Upload progress */} {/* Upload progress */}
@@ -625,6 +649,7 @@ export const Files = () => {
onRenamingChange={setRenamingName} onRenamingChange={setRenamingName}
getMatchingTasks={getMatchingTasks} getMatchingTasks={getMatchingTasks}
onRunTask={handleRunTask} onRunTask={handleRunTask}
onCreateWorkspace={handleCreateWorkspace}
scrollRef={fileScrollRef} scrollRef={fileScrollRef}
/> />
)} )}
@@ -645,6 +670,10 @@ export const Files = () => {
<FolderPlus className="mr-2 h-4 w-4" /> <FolderPlus className="mr-2 h-4 w-4" />
New folder New folder
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
</ContextMenu> </ContextMenu>
)} )}
@@ -5,8 +5,19 @@ import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { generateSlug } from 'helpers/slug'; import { generateSlug } from 'helpers/slug';
import { useUserState } from '@/state/useUserState';
import { useWorkspacesState } from '@/state/useWorkspacesState'; import { useWorkspacesState } from '@/state/useWorkspacesState';
import type { WorkspaceDefinition } from '@/components/Workspace'; import type { WorkspaceDefinition } from '@/components/Workspace';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { import {
SELECTED_WORKSPACE_KEY, SELECTED_WORKSPACE_KEY,
CREATING_WORKSPACE_KEY, CREATING_WORKSPACE_KEY,
@@ -28,8 +39,10 @@ export const WorkspaceListApp = () => {
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, ''); const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, ''); const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0); const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [deleting, setDeleting] = useState<WorkspaceDefinition | null>(null);
const isWorkspacesPage = location.pathname === '/workspaces'; const isWorkspacesPage = location.pathname === '/workspaces';
const filtered = search const filtered = search
? workspaces.filter((ws) => { ? workspaces.filter((ws) => {
@@ -46,18 +59,26 @@ export const WorkspaceListApp = () => {
setName(ws.name); setName(ws.name);
setDescription(ws.description ?? ''); setDescription(ws.description ?? '');
setTemplateIdx(ws.templateIdx ?? 0); setTemplateIdx(ws.templateIdx ?? 0);
const cwdPath = !ws.cwd || ws.cwd === '~' ? '/' : ws.cwd.replace(/^~\//, '/');
setFilePath(cwdPath);
}; };
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => { const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
ev.stopPropagation(); ev.stopPropagation();
setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id)); setDeleting(ws);
if (selected === ws.id) setSelected(null); };
const layoutKey = `ws-layout-${ws.id}`; const confirmDelete = () => {
if (!deleting) return;
setWorkspaces((prev) => prev.filter((w) => w.id !== deleting.id));
if (selected === deleting.id) setSelected(null);
const layoutKey = `ws-layout-${deleting.id}`;
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {}; const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const { [layoutKey]: _, ...rest } = currentState; const { [layoutKey]: _, ...rest } = currentState;
queryClient.setQueryData(['WORKSPACES_STATE'], rest); queryClient.setQueryData(['WORKSPACES_STATE'], rest);
client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {}); client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {});
setDeleting(null);
}; };
const handleClick = (ws: WorkspaceDefinition) => { const handleClick = (ws: WorkspaceDefinition) => {
@@ -88,6 +109,7 @@ export const WorkspaceListApp = () => {
setName(generateSlug()); setName(generateSlug());
setDescription(''); setDescription('');
setTemplateIdx(0); setTemplateIdx(0);
setFilePath('/');
setCreating(true); setCreating(true);
if (!isWorkspacesPage) navigate('/workspaces'); if (!isWorkspacesPage) navigate('/workspaces');
}} }}
@@ -146,6 +168,23 @@ export const WorkspaceListApp = () => {
</p> </p>
)} )}
</div> </div>
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router'; import { Link, Navigate, useNavigate, useSearchParams } from 'react-router';
import { LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react'; import { FolderOpen, LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
@@ -9,6 +10,7 @@ import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/component
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace'; import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { generateSlug, slugify } from 'helpers/slug'; import { generateSlug, slugify } from 'helpers/slug';
import { Files } from '../Files';
import { appRegistry } from './app-registry'; import { appRegistry } from './app-registry';
import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants'; import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
@@ -389,6 +391,14 @@ const CreatePanel = () => {
); );
}; };
// --- CwdFileBrowser (basePath-constrained file browser for workspace form) ---
const CwdFileBrowser = () => {
const [currentPath] = useUserState<string>('files/currentPath', '/');
const [basePath] = useState(currentPath);
return <Files basePath={basePath} />;
};
// --- New Workspace Layout --- // --- New Workspace Layout ---
const newWsLayout: LayoutNode = { const newWsLayout: LayoutNode = {
@@ -403,7 +413,7 @@ const newWsLayout: LayoutNode = {
direction: 'horizontal', direction: 'horizontal',
children: [ children: [
{ node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 }, { node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 },
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser' }, size: 70 }, { node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser-cwd' }, size: 70 },
], ],
}, },
size: 50, size: 50,
@@ -425,6 +435,7 @@ const newWsLayout: LayoutNode = {
const newWsRegistry = { const newWsRegistry = {
...appRegistry, ...appRegistry,
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel }, 'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel }, 'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel }, 'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
@@ -440,6 +451,7 @@ const WorkspacePreviewEmpty = () => {
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, ''); const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, ''); const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0); const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
if (creating || editingId) return <NewWorkspaceForm />; if (creating || editingId) return <NewWorkspaceForm />;
@@ -447,6 +459,7 @@ const WorkspacePreviewEmpty = () => {
setName(generateSlug()); setName(generateSlug());
setDescription(''); setDescription('');
setTemplateIdx(0); setTemplateIdx(0);
setFilePath('/');
setCreating(true); setCreating(true);
}; };
@@ -505,6 +518,7 @@ export const WorkspaceListScreen = () => {
}; };
export const NewWorkspaceRedirect = () => { export const NewWorkspaceRedirect = () => {
const navigate = useNavigate();
const [params] = useSearchParams(); const [params] = useSearchParams();
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, ''); const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, ''); const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
@@ -514,16 +528,16 @@ export const NewWorkspaceRedirect = () => {
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null); const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const [, setFilePath] = useUserState<string>('files/currentPath', '/'); const [, setFilePath] = useUserState<string>('files/currentPath', '/');
const name = params.get('name'); useEffect(() => {
const cwd = params.get('cwd'); setSelected(null);
setEditing(null);
setName(params.get('name') || generateSlug());
setDescription('');
setTemplateIdx(0);
setFilePath(params.get('cwd') || '/');
setCreating(true);
navigate('/workspaces', { replace: true });
}, []);
setSelected(null); return null;
setEditing(null);
setName(name || generateSlug());
setDescription('');
setTemplateIdx(0);
setCreating(true);
if (cwd) setFilePath(cwd);
return <Navigate to="/workspaces" replace />;
}; };
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react'; import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import type { AppRegistry } from '@/components/Workspace'; import type { AppRegistry } from '@/components/Workspace';
@@ -39,12 +39,24 @@ const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claud
return <ChatPanel chat={opencode} provider="opencode" availableModels={models} onProviderChange={onProviderChange} />; return <ChatPanel chat={opencode} provider="opencode" availableModels={models} onProviderChange={onProviderChange} />;
}; };
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
const FileBrowserWrapper = () => {
const { cwd } = useWorkspace();
const basePath = cwdToPath(cwd);
return <Files basePath={basePath} />;
};
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />; const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
const EMPTY_TERMINALS: Record<string, string> = {};
const TerminalWrapper = ({ panelId }: { panelId: string }) => { const TerminalWrapper = ({ panelId }: { panelId: string }) => {
const { workspaceId } = useWorkspace(); const { workspaceId, cwd } = useWorkspace();
const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default'; const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default';
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {}); const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId]; const sessionId = terminals[panelId];
@@ -54,16 +66,27 @@ const TerminalWrapper = ({ panelId }: { panelId: string }) => {
} }
}, [panelId, sessionId, setTerminals]); }, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (!sessionId) return null; if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} />; return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} />;
}; };
const HostTerminalWrapper = ({ panelId }: { panelId: string }) => { const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
const { user } = useAuth(); const { user } = useAuth();
const { workspaceId } = useWorkspace(); const { workspaceId, cwd } = useWorkspace();
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default'; const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {}); const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId]; const sessionId = terminals[panelId];
@@ -73,6 +96,15 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
} }
}, [panelId, sessionId, setTerminals]); }, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (user?.role !== 'Super Admin') { if (user?.role !== 'Super Admin') {
return ( return (
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
@@ -83,12 +115,12 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
if (!sessionId) return null; if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} />; return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
}; };
export const appRegistry: AppRegistry = { export const appRegistry: AppRegistry = {
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget }, 'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => <Files /> }, 'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> }, 'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> }, 'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper }, 'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
+3 -1
View File
@@ -19,6 +19,7 @@ type WSData = {
provider: 'claude' | 'opencode' | 'terminal'; provider: 'claude' | 'opencode' | 'terminal';
sandboxed: boolean; sandboxed: boolean;
sessionId?: string; sessionId?: string;
cwd?: string;
}; };
const handlers: Record<string, typeof claudeWebsocket> = { const handlers: Record<string, typeof claudeWebsocket> = {
@@ -45,8 +46,9 @@ async function upgradeWs(req: Request, server: any, provider: 'claude' | 'openco
const url = new URL(req.url); const url = new URL(req.url);
const sessionId = url.searchParams.get('sessionId') ?? undefined; const sessionId = url.searchParams.get('sessionId') ?? undefined;
const sandboxed = url.searchParams.get('sandboxed') !== 'false'; const sandboxed = url.searchParams.get('sandboxed') !== 'false';
const cwd = url.searchParams.get('cwd') ?? undefined;
const ok = server.upgrade(req, { const ok = server.upgrade(req, {
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId }, data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd },
}); });
if (!ok) return new Response('Upgrade failed', { status: 500 }); if (!ok) return new Response('Upgrade failed', { status: 500 });
} catch { } catch {
@@ -1,12 +1,25 @@
FROM imbios/bun-node:22-slim FROM imbios/bun-node:22-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \ && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* python3 make g++ zsh git curl wget ca-certificates \
fortune-mod cowsay sudo gosu \
zip unzip tree btop net-tools tmux \
&& apt-get clean
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
&& rm nvim-linux-x86_64.tar.gz
ENV PATH="/opt/nvim-linux-x86_64/bin:${PATH}"
RUN git clone --depth 1 https://github.com/LazyVim/starter /opt/lazyvim-starter \
&& rm -rf /opt/lazyvim-starter/.git
WORKDIR /app WORKDIR /app
COPY pty-sidecar.mjs /app/pty-sidecar.mjs COPY pty-sidecar.mjs /app/pty-sidecar.mjs
COPY entrypoint.sh /app/entrypoint.sh
COPY templates /opt/terminal-templates COPY templates /opt/terminal-templates
RUN npm init -y \ RUN npm init -y \
@@ -23,6 +36,7 @@ RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VE
&& chmod +x /usr/local/bin/eza \ && chmod +x /usr/local/bin/eza \
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man && rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
RUN mkdir -p /home/officer
WORKDIR /home/officer WORKDIR /home/officer
@@ -32,4 +46,4 @@ ENV PATH="/usr/games:${PATH}"
EXPOSE 5337 EXPOSE 5337
CMD ["node", "/app/pty-sidecar.mjs"] ENTRYPOINT ["/app/entrypoint.sh"]
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -e
USERNAME="${TERMINAL_USER:-officer}"
USER_UID="${TERMINAL_UID:-1000}"
USER_GID="${TERMINAL_GID:-1000}"
# Remove any existing user/group with the target UID/GID
EXISTING_USER=$(getent passwd "$USER_UID" | cut -d: -f1)
if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "$USERNAME" ]; then
userdel "$EXISTING_USER" 2>/dev/null || true
fi
EXISTING_GROUP=$(getent group "$USER_GID" | cut -d: -f1)
if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "$USERNAME" ]; then
groupdel "$EXISTING_GROUP" 2>/dev/null || true
fi
# Create group and user
groupadd -g "$USER_GID" "$USERNAME" 2>/dev/null || true
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/officer "$USERNAME" 2>/dev/null || true
# Passwordless sudo
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/terminal-user
chmod 0440 /etc/sudoers.d/terminal-user
# Seed LazyVim config if not present
if [ ! -d /home/officer/.config/nvim ]; then
mkdir -p /home/officer/.config
cp -r /opt/lazyvim-starter /home/officer/.config/nvim
chown -R "$USER_UID:$USER_GID" /home/officer/.config
fi
# Run sidecar as the user
exec gosu "$USER_UID:$USER_GID" node /app/pty-sidecar.mjs
+5
View File
@@ -45,6 +45,11 @@ const ensureUserFiles = async (homeDir) => {
await cp(join(templateDir, '.zshrc'), zshrcPath); await cp(join(templateDir, '.zshrc'), zshrcPath);
} }
const tmuxconfPath = join(homeDir, '.tmux.conf');
if (!existsSync(tmuxconfPath)) {
await cp(join(templateDir, '.tmux.conf'), tmuxconfPath);
}
const starshipPath = join(homeDir, '.config', 'starship-officer.toml'); const starshipPath = join(homeDir, '.config', 'starship-officer.toml');
if (!existsSync(starshipPath)) { if (!existsSync(starshipPath)) {
await cp(join(templateDir, 'starship-officer.toml'), starshipPath); await cp(join(templateDir, 'starship-officer.toml'), starshipPath);
@@ -0,0 +1,100 @@
########## TPM AUTO-INSTALL + SESSION PERSISTENCE ##########
# Auto-install TPM if missing
if-shell '[ ! -d ~/.tmux/plugins/tpm ]' \
'run-shell "git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm"'
# Plugin list
set -g @plugin 'tmux-plugins/tpm'
# remap prefix from 'C-b' to 'C-a'
unbind C-b
set-option -g prefix C-a
bind-key C-a send-prefix
set -g base-index 1
# split panes using | and -
unbind '"'
unbind %
bind | split-window -h
bind - split-window -v
# reload config file (change file location to your the tmux.conf you want to use)
unbind r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
# switch panes using Alt-arrow without prefix
bind -n M-Left select-pane -L
bind -n M-Right select-pane -R
bind -n M-Up select-pane -U
bind -n M-Down select-pane -D
# switch panes using Alt-HJKL without prefix
bind -n M-h select-pane -L
bind -n M-l select-pane -R
bind -n M-k select-pane -U
bind -n M-j select-pane -D
# Enable mouse control (clickable windows, panes, resizable panes)
# don't rename windows automatically
set-option -g allow-rename off
######################
### DESIGN CHANGES ###
######################
# loud or quiet?
set -g visual-activity off
set -g visual-bell off
set -g visual-silence off
setw -g monitor-activity off
set -g bell-action none
# modes
setw -g clock-mode-colour colour12
setw -g mode-style 'fg=colour1 bg=colour18 bold'
# panes
set -g pane-border-style 'fg=colour19 bg=colour0'
set -g pane-active-border-style 'bg=colour0 fg=colour9'
# statusbar
set -g status-position bottom
set -g status-justify left
set -g status-style 'bg=colour2 fg=colour23'
# set -g status-left '#[fg=white,bg=black,bold] pastilhas #[default]'
set -g status-left '#[fg=#ffffff,bg=#000000,bold] #{USER}@#H #[default]'
# set -g status-left-length 20
set -g status-right '#[fg=#ffffff,bg=colour1] %d/%m #[fg=#ffffff,bg=colour8] %H:%M:%S '
set -g status-right-length 50
set -g status-left-length 20
setw -g window-status-current-style 'fg=colour1 bg=colour19 bold'
setw -g window-status-current-format ' #I#[fg=colour249]:#[fg=colour255]#W#[fg=colour249]#F '
setw -g window-status-style 'fg=colour9 bg=colour18'
setw -g window-status-format ' #I#[fg=colour237]:#[fg=colour250]#W#[fg=colour244]#F '
setw -g window-status-bell-style 'fg=colour255 bg=colour1 bold'
# ...existing code...
# messages
set -g message-style 'fg=#ffffff bg=red bold'
# Change the font color for the exit pane confirmation message
set -g message-command-style 'fg=#ffffff bg=red bold'
# ...existing code...
# messages
# set -g message-style 'fg=colour232 bg=colour16 bold'
##########################
### END DESIGN CHANGES ###
##########################
##########################
### EASY MOUSE SCROLL ###
##########################
set -g mouse on
set -ga terminal-overrides ',*256color*:smcup@:rmcup@'
+50 -13
View File
@@ -5,13 +5,14 @@ import { fileURLToPath } from 'node:url';
import { getHomeDir } from '@@/data-path'; import { getHomeDir } from '@@/data-path';
import { officerdb, Users } from 'officerdb'; import { officerdb, Users } from 'officerdb';
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string }; type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string };
type ShellInfo = { command: string; args: string[]; name: string }; type ShellInfo = { command: string; args: string[]; name: string };
type BridgeSession = { type BridgeSession = {
client: ServerWebSocket<WSData>; client: ServerWebSocket<WSData>;
sidecar: WebSocket | null; sidecar: WebSocket | null;
dockerId: string; dockerId: string;
port: number; port: number;
pendingMessages: string[];
}; };
type ContainerInfo = { type ContainerInfo = {
@@ -99,7 +100,7 @@ const ensureDockerImage = () => {
dockerImageReady = true; dockerImageReady = true;
}; };
const startDockerSidecar = (port: number, homeDir: string, userId: number): { dockerId: string } => { const startDockerSidecar = (port: number, homeDir: string, userId: number, email: string): { dockerId: string } => {
ensureDockerImage(); ensureDockerImage();
const dockerPath = Bun.which('docker') ?? 'docker'; const dockerPath = Bun.which('docker') ?? 'docker';
const dockerId = `officer-terminal-${userId}`; const dockerId = `officer-terminal-${userId}`;
@@ -110,12 +111,15 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number): { do
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' }); Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
} }
let userArgs: string[] = []; const username = (email.split('@')[0] ?? 'officer').replace(/[^a-z0-9_-]/g, '_').slice(0, 32);
let uid = 1000;
let gid = 1000;
try { try {
const stats = statSync(homeDir); const stats = statSync(homeDir);
userArgs = ['--user', `${stats.uid}:${stats.gid}`]; uid = stats.uid;
gid = stats.gid;
} catch { } catch {
userArgs = []; // fallback to defaults
} }
const run = Bun.spawnSync({ const run = Bun.spawnSync({
@@ -127,13 +131,18 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number): { do
dockerId, dockerId,
'--restart', '--restart',
'unless-stopped', 'unless-stopped',
...userArgs,
'-p', '-p',
`127.0.0.1:${port}:${port}`, `127.0.0.1:${port}:${port}`,
'-e', '-e',
`TERMINAL_PTY_PORT=${port}`, `TERMINAL_PTY_PORT=${port}`,
'-e', '-e',
'TERMINAL_PTY_HOST=0.0.0.0', 'TERMINAL_PTY_HOST=0.0.0.0',
'-e',
`TERMINAL_USER=${username}`,
'-e',
`TERMINAL_UID=${uid}`,
'-e',
`TERMINAL_GID=${gid}`,
'-v', '-v',
`${homeDir}:/home/officer`, `${homeDir}:/home/officer`,
'-w', '-w',
@@ -211,7 +220,7 @@ const ensureDockerContainer = async (email: string, userId: number, homeDir: str
} }
const port = existing?.port ?? getAvailablePort(map, userId); const port = existing?.port ?? getAvailablePort(map, userId);
const docker = startDockerSidecar(port, homeDir, userId); const docker = startDockerSidecar(port, homeDir, userId, email);
const next = { userId, email, dockerId: docker.dockerId, port }; const next = { userId, email, dockerId: docker.dockerId, port };
map[email] = next; map[email] = next;
await saveContainerMap(map); await saveContainerMap(map);
@@ -268,6 +277,12 @@ export const initTerminalSidecars = async () => {
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' }; const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
const containerHome = '/home/officer'; const containerHome = '/home/officer';
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
return home;
};
export const terminalWebsocket = { export const terminalWebsocket = {
async open(ws: ServerWebSocket<WSData>) { async open(ws: ServerWebSocket<WSData>) {
const { email, role, sandboxed } = ws.data; const { email, role, sandboxed } = ws.data;
@@ -278,16 +293,20 @@ export const terminalWebsocket = {
} }
if (!sandboxed) { if (!sandboxed) {
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] };
sessions.set(ws, session);
let sidecar: WebSocket | null = null; let sidecar: WebSocket | null = null;
try { try {
sidecar = await connectSidecar(HOST_SIDECAR_PORT); sidecar = await connectSidecar(HOST_SIDECAR_PORT);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect host sidecar'; const message = err instanceof Error ? err.message : 'Failed to connect host sidecar';
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
sessions.delete(ws);
return; return;
} }
sessions.set(ws, { client: ws, sidecar, dockerId: '', port: HOST_SIDECAR_PORT }); session.sidecar = sidecar;
sidecar.addEventListener('message', (ev) => { sidecar.addEventListener('message', (ev) => {
try { try {
@@ -306,11 +325,14 @@ export const terminalWebsocket = {
type: 'init', type: 'init',
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`, sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] }, shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
cwd: process.env.HOME, cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
homeDir: process.env.HOME, homeDir: process.env.HOME,
userLabel: email, userLabel: email,
}), }),
); );
for (const msg of session.pendingMessages) sidecar.send(msg);
session.pendingMessages = [];
return; return;
} }
@@ -319,6 +341,9 @@ export const terminalWebsocket = {
mkdirSync(userRoot, { recursive: true }); mkdirSync(userRoot, { recursive: true });
mkdirSync(cwd, { recursive: true }); mkdirSync(cwd, { recursive: true });
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] };
sessions.set(ws, session);
let sidecar: WebSocket | null = null; let sidecar: WebSocket | null = null;
let info: ContainerInfo | undefined; let info: ContainerInfo | undefined;
try { try {
@@ -335,10 +360,13 @@ export const terminalWebsocket = {
} }
sendOutput(ws, '\r\n[Process exited]\r\n'); sendOutput(ws, '\r\n[Process exited]\r\n');
if (info) stopDockerSidecar(info.dockerId); if (info) stopDockerSidecar(info.dockerId);
sessions.delete(ws);
return; return;
} }
sessions.set(ws, { client: ws, sidecar, dockerId: info.dockerId, port: info.port }); session.sidecar = sidecar;
session.dockerId = info.dockerId;
session.port = info.port;
sidecar.addEventListener('message', (ev) => { sidecar.addEventListener('message', (ev) => {
try { try {
@@ -357,19 +385,28 @@ export const terminalWebsocket = {
type: 'init', type: 'init',
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`, sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
shell: containerShell, shell: containerShell,
cwd: containerHome, cwd: resolveCwd(containerHome, ws.data.cwd),
homeDir: containerHome, homeDir: containerHome,
userLabel: email, userLabel: email,
}), }),
); );
for (const msg of session.pendingMessages) sidecar.send(msg);
session.pendingMessages = [];
}, },
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) { message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
const session = sessions.get(ws); const session = sessions.get(ws);
if (!session?.sidecar || session.sidecar.readyState !== WebSocket.OPEN) return; if (!session) return;
const payload = typeof raw === 'string' ? raw : raw.toString();
if (!session.sidecar || session.sidecar.readyState !== WebSocket.OPEN) {
session.pendingMessages.push(payload);
return;
}
try { try {
const payload = typeof raw === 'string' ? raw : raw.toString();
session.sidecar.send(payload); session.sidecar.send(payload);
} catch { } catch {
// ignore // ignore
@@ -3,23 +3,26 @@ import { ChevronRight, Home } from 'lucide-react';
type BreadcrumbProps = { type BreadcrumbProps = {
path: string; path: string;
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
basePath?: string;
}; };
export const Breadcrumb = ({ path, onNavigate }: BreadcrumbProps) => { export const Breadcrumb = ({ path, onNavigate, basePath = '/' }: BreadcrumbProps) => {
const segments = path.split('/').filter(Boolean); const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path;
const segments = relativePath.split('/').filter(Boolean);
return ( return (
<nav className="flex items-center gap-1 text-sm flex-wrap"> <nav className="flex items-center gap-1 text-sm flex-wrap">
<button <button
onClick={() => onNavigate('/')} onClick={() => onNavigate(basePath)}
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium" className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
> >
<Home className="h-4 w-4" /> <Home className="h-4 w-4" />
<span>home</span> <span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
</button> </button>
{segments.map((segment, i) => { {segments.map((segment, i) => {
const segmentPath = '/' + segments.slice(0, i + 1).join('/'); const relative = '/' + segments.slice(0, i + 1).join('/');
const segmentPath = basePath === '/' ? relative : basePath + relative;
const isLast = i === segments.length - 1; const isLast = i === segments.length - 1;
return ( return (
+16 -7
View File
@@ -18,6 +18,7 @@ export type TerminalViewProps = {
wsPath?: string; wsPath?: string;
sessionId?: string; sessionId?: string;
sandboxed?: boolean; sandboxed?: boolean;
cwd?: string;
fontSize?: number; fontSize?: number;
fontFamily?: string; fontFamily?: string;
theme?: TerminalTheme; theme?: TerminalTheme;
@@ -34,13 +35,14 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
selectionBackground: '#3a3a5e', selectionBackground: '#3a3a5e',
}; };
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean) => { const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = localStorage.getItem('BEARER_TOKEN') ?? ''; const token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?'; const separator = wsPath.includes('?') ? '&' : '?';
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`; let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`; if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
if (sandboxed === false) url += '&sandboxed=false'; if (sandboxed === false) url += '&sandboxed=false';
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
return url; return url;
}; };
@@ -50,6 +52,7 @@ export const TerminalView = ({
wsPath = '/api/terminal/ws', wsPath = '/api/terminal/ws',
sessionId, sessionId,
sandboxed = true, sandboxed = true,
cwd,
fontSize = 14, fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace', fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme, theme,
@@ -99,6 +102,13 @@ export const TerminalView = ({
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.open(container); term.open(container);
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (viewport) {
viewport.style.scrollbarWidth = 'none';
viewport.style.overflow = 'hidden';
}
fitAddon.fit(); fitAddon.fit();
if (autoFocus) term.focus(); if (autoFocus) term.focus();
@@ -106,7 +116,7 @@ export const TerminalView = ({
fitAddonRef.current = fitAddon; fitAddonRef.current = fitAddon;
onReadyRef.current?.(term); onReadyRef.current?.(term);
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed)); const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd));
wsRef.current = ws; wsRef.current = ws;
const handleOpen = () => { const handleOpen = () => {
@@ -180,6 +190,7 @@ export const TerminalView = ({
wsPath, wsPath,
sessionId, sessionId,
sandboxed, sandboxed,
cwd,
fontSize, fontSize,
fontFamily, fontFamily,
background, background,
@@ -190,10 +201,8 @@ export const TerminalView = ({
]); ]);
return ( return (
<div <div className={className} style={{ backgroundColor: background, overflow: 'hidden', ...style }}>
ref={containerRef} <div ref={containerRef} style={{ width: '100%', height: '100%' }} />
className={className} </div>
style={{ backgroundColor: background, ...style }}
/>
); );
}; };