claude-code streaming chat, desktop remote viewer, new-automation route, tiktok task v4, misc fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,13 +9,14 @@ import { appRegistryMetas as projectMetas } from '../apps/Projects';
|
||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { useAppRegistry } from './useAppRegistry';
|
||||
import { useUserApps } from 'state/useUserApps';
|
||||
import { createUserAppPanel } from '../apps/UserApp/UserAppPanel';
|
||||
import { createUserAppHeader } from '../apps/UserApp/UserAppHeader';
|
||||
import { resolveIcon } from '../utils/resolve-icon';
|
||||
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas];
|
||||
const apps = [...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, ...fileViewerMetas, ...dashboardMetas, ...projectMetas, ...chatHistoryMetas, ...previewMetas, ...widgetMetas, ...desktopMetas];
|
||||
|
||||
export const AppRegistry = () => {
|
||||
const { registerApp } = useAppRegistry(apps);
|
||||
|
||||
@@ -18,6 +18,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
bedrock: 'Amazon Bedrock',
|
||||
'google-vertex': 'Google Vertex AI',
|
||||
'azure-openai': 'Azure OpenAI',
|
||||
'claude-code': 'Claude Code',
|
||||
};
|
||||
|
||||
type ModelSelectorProps = {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MonitorSmartphone } from 'lucide-react';
|
||||
|
||||
export const DesktopHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<MonitorSmartphone className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Remote Desktop</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
|
||||
export type DesktopViewProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
const NOVNC_URL = '/novnc/rfb.js';
|
||||
|
||||
const buildWsUrl = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
return `${protocol}//${window.location.host}/api/desktop/ws?token=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
type RFBInstance = {
|
||||
resizeSession: boolean;
|
||||
scaleViewport: boolean;
|
||||
focusOnClick: boolean;
|
||||
disconnect: () => void;
|
||||
sendCredentials: (creds: { password: string }) => void;
|
||||
addEventListener: (type: string, listener: (ev: CustomEvent) => void) => void;
|
||||
};
|
||||
|
||||
let rfbModulePromise: Promise<{ default: new (target: HTMLElement, url: string, options?: { credentials?: { password?: string } }) => RFBInstance }> | null = null;
|
||||
|
||||
const loadRFB = () => {
|
||||
if (!rfbModulePromise) {
|
||||
rfbModulePromise = import(/* @vite-ignore */ NOVNC_URL) as typeof rfbModulePromise;
|
||||
}
|
||||
return rfbModulePromise!;
|
||||
};
|
||||
|
||||
export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rfbRef = useRef<RFBInstance | null>(null);
|
||||
const isMounted = useMounted();
|
||||
const client = useClient();
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const connect = async () => {
|
||||
let password = '';
|
||||
try {
|
||||
const res = await client.get<{ password: string }>('/desktop/vnc-password');
|
||||
password = res.password;
|
||||
} catch {
|
||||
if (disposed) return;
|
||||
setStatus('error');
|
||||
setErrorMsg('Failed to fetch VNC password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
let RFB: Awaited<ReturnType<typeof loadRFB>>['default'];
|
||||
try {
|
||||
const mod = await loadRFB();
|
||||
RFB = mod.default;
|
||||
} catch (err) {
|
||||
console.error('[desktop] Failed to load noVNC:', err);
|
||||
if (disposed) return;
|
||||
setStatus('error');
|
||||
setErrorMsg('Failed to load noVNC library');
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposed) return;
|
||||
|
||||
const wsUrl = buildWsUrl();
|
||||
const rfb = new RFB(container, wsUrl, {
|
||||
credentials: { password },
|
||||
});
|
||||
|
||||
rfb.resizeSession = true;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.focusOnClick = true;
|
||||
rfbRef.current = rfb;
|
||||
|
||||
rfb.addEventListener('connect', () => {
|
||||
if (!disposed) setStatus('connected');
|
||||
});
|
||||
|
||||
rfb.addEventListener('disconnect', (ev: CustomEvent) => {
|
||||
if (disposed) return;
|
||||
setStatus('disconnected');
|
||||
if (!ev.detail.clean) {
|
||||
setErrorMsg('Connection lost');
|
||||
}
|
||||
});
|
||||
|
||||
rfb.addEventListener('credentialsrequired', () => {
|
||||
rfb.sendCredentials({ password });
|
||||
});
|
||||
|
||||
rfb.addEventListener('securityfailure', (ev: CustomEvent) => {
|
||||
if (!disposed) {
|
||||
setStatus('error');
|
||||
setErrorMsg(ev.detail.reason || 'Security failure');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (rfbRef.current) {
|
||||
try { rfbRef.current.disconnect(); } catch { /* ignore */ }
|
||||
rfbRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isMounted, client]);
|
||||
|
||||
return (
|
||||
<div className={className} style={{ backgroundColor: '#1a1a2e', overflow: 'hidden', position: 'relative', ...style }}>
|
||||
{status === 'connecting' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
Connecting to desktop...
|
||||
</div>
|
||||
)}
|
||||
{(status === 'disconnected' || status === 'error') && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
||||
{errorMsg || 'Disconnected from desktop'}
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { DesktopView } from './DesktopView';
|
||||
|
||||
export const DesktopWrapper = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
if (user?.role !== 'Super Admin') {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||
Remote Desktop requires Super Admin permissions.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DesktopView className="h-full w-full" />;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MonitorSmartphone } from 'lucide-react';
|
||||
import { DesktopWrapper } from './DesktopWrapper';
|
||||
import { DesktopHeader } from './DesktopHeader';
|
||||
|
||||
export { DesktopView, type DesktopViewProps } from './DesktopView';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/desktop',
|
||||
name: 'Remote Desktop',
|
||||
icon: MonitorSmartphone,
|
||||
component: DesktopWrapper,
|
||||
header: DesktopHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare } from 'lucide-react';
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
@@ -124,10 +124,10 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Dashboard here
|
||||
</ContextMenuItem>
|
||||
{/* <ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download video
|
||||
</ContextMenuItem> */}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
+11
-2
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Play, Square, CircleCheck } from 'lucide-react';
|
||||
import { X, Play, Square, CircleCheck, CircleX } from 'lucide-react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
@@ -54,7 +54,7 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
||||
const seenResultRef = useRef(false);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
// Track tool/result messages from chat.messages (idempotent during render)
|
||||
// Track tool/result/error messages from chat.messages (idempotent during render)
|
||||
for (const m of chat.messages) {
|
||||
if (m.role === 'tool' && 'toolCallId' in m) {
|
||||
if (!seenToolIdsRef.current.has(m.toolCallId)) {
|
||||
@@ -72,6 +72,10 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
||||
seenResultRef.current = true;
|
||||
accRef.current.push(m);
|
||||
}
|
||||
if (m.role === 'error') {
|
||||
const alreadyHas = accRef.current.some((a) => a.role === 'error' && 'text' in a && a.text === m.text);
|
||||
if (!alreadyHas) accRef.current.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
// Capture assistant text when streaming is committed (streamingText goes non-empty → empty)
|
||||
@@ -170,6 +174,11 @@ const PiMonoInner = ({ defaultInput, cwd, initialModel, taskInfo, sandboxed }: P
|
||||
<Square className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</button>
|
||||
) : accRef.current.some((m) => m.role === 'error') ? (
|
||||
<span className="flex items-center gap-2 text-sm text-red-500 font-medium">
|
||||
<CircleX className="h-4 w-4" />
|
||||
Task failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2 text-sm text-green-500 font-medium">
|
||||
<CircleCheck className="h-4 w-4" />
|
||||
|
||||
@@ -15,6 +15,8 @@ export { FileViewerView, FileViewerProvider, FileViewerHeader, FileViewerBody, F
|
||||
export type { FileType } from './apps/FileViewer';
|
||||
export { TerminalView } from './apps/Terminal';
|
||||
export type { TerminalViewProps } from './apps/Terminal';
|
||||
export { DesktopView } from './apps/Desktop';
|
||||
export type { DesktopViewProps } from './apps/Desktop';
|
||||
export { DashboardListApp, DashboardPreview, SELECTED_DASHBOARD_KEY, CREATING_DASHBOARD_KEY, EDITING_DASHBOARD_KEY, NEW_DASH_NAME_KEY, NEW_DASH_DESC_KEY, NEW_DASH_TEMPLATE_KEY } from './apps/Dashboards';
|
||||
export { ProjectListApp, ProjectPreview, SELECTED_PROJECT, CREATING_PROJECT, EDITING_PROJECT, NEW_PROJ_NAME, NEW_PROJ_DESC, NEW_PROJ_TEMPLATE, NEW_PROJ_TYPE, NEW_PROJ_HAS_BACKEND, NEW_PROJ_HAS_AUTH, NEW_PROJ_PREVIEW_LAYOUT } from './apps/Projects';
|
||||
export { createUserAppPanel, createUserAppHeader } from './apps/UserApp';
|
||||
|
||||
@@ -60,6 +60,7 @@ export function useVisiblePiModels() {
|
||||
const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));
|
||||
|
||||
return models.filter((m) => {
|
||||
if (m.provider === 'claude-code') return true;
|
||||
const isExplicitlyAllowed = allowedSet.has(modelKey(m));
|
||||
const isFromNewProvider = !allowedProviderSet.has(m.provider);
|
||||
return isExplicitlyAllowed || isFromNewProvider;
|
||||
@@ -75,5 +76,5 @@ export function useEnabledPiModels() {
|
||||
if (allowed.length === 0) return models;
|
||||
|
||||
const allowedSet = new Set(allowed);
|
||||
return models.filter((m) => allowedSet.has(modelKey(m)));
|
||||
return models.filter((m) => m.provider === 'claude-code' || allowedSet.has(modelKey(m)));
|
||||
}
|
||||
|
||||
@@ -87,8 +87,8 @@ export type UserState = Record<string, unknown>;
|
||||
export const DEFAULT_SETTINGS: UserSettings = {
|
||||
chat: {
|
||||
defaultProvider: 'pi',
|
||||
defaultModel: null,
|
||||
defaultProjectModel: null,
|
||||
defaultModel: 'claude-code',
|
||||
defaultProjectModel: 'claude-code',
|
||||
systemPrompt: '',
|
||||
temperature: 1,
|
||||
defaultPwd: '~',
|
||||
@@ -100,7 +100,7 @@ export const DEFAULT_SETTINGS: UserSettings = {
|
||||
},
|
||||
tasks: {
|
||||
defaultProvider: 'pi',
|
||||
defaultModel: null,
|
||||
defaultModel: 'claude-code',
|
||||
},
|
||||
appearance: {
|
||||
colorMode: 'light',
|
||||
|
||||
Reference in New Issue
Block a user