Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/core-ui/src/SignedInShell.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {PersonalityLibrary} from './PersonalityLibrary';
import {WorkspaceModes,editorBounds,useNabuAvailability} from './WorkspaceModes';
import {TradingWorkspace} from './TradingWorkspace';
import {AppsWorkspace} from './AppsWorkspace';
import {SpecialtyLibrary} from './SpecialtyLibrary';
Expand Down Expand Up @@ -31,7 +32,7 @@ import { CommandPalette } from './CommandPalette';
import { ChatSearchController } from './chat-search';
import type { Command } from './CommandPalette';

type Page = 'trading' | 'apps' | 'approvals' | 'chat' | 'genvr' | 'agents' | 'board' | 'memory' | 'skills' | 'mcp' | 'terminal' | 'account' | 'settings' | 'about' | 'specialties' | 'personalities';
type Page = 'code' | 'trading' | 'apps' | 'approvals' | 'chat' | 'genvr' | 'agents' | 'board' | 'memory' | 'skills' | 'mcp' | 'terminal' | 'account' | 'settings' | 'about' | 'specialties' | 'personalities';
/** Sections that own the sidebar with their own places (the chat list shows only in Chat). */
const OWN_SIDEBAR: ReadonlyArray<Page> = ['memory', 'settings', 'agents', 'skills', 'mcp', 'terminal','specialties','personalities','board','approvals','apps','trading'];
const pages: ReadonlyArray<{ key: Page; label: string; icon: IconName | 'board' }> = [
Expand All @@ -56,6 +57,10 @@ const pages: ReadonlyArray<{ key: Page; label: string; icon: IconName | 'board'
export function SignedInShell({ client, notice }: { client: SessionClient; notice: ReactNode }) {
const state = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot);
const [page, setPage] = useState<Page>('chat');
const nativeEditor=useNabuAvailability();
useEffect(()=>{if(page!=='code'){const {top,bottom}=editorBounds();void window.dproApp?.nabu?.select('chat',top,bottom).catch(()=>{});}},[page]);
useEffect(()=>()=>{void window.dproApp?.nabu?.select('chat',48,32).catch(()=>{});},[]);
const openCode=()=>{setPage('code');const {top,bottom}=editorBounds();void window.dproApp?.nabu?.select('code',top,bottom).catch(()=>{});};
const appearance = useAppTheme();
const theme = appearance.theme;
const setTheme = (fn: (v: 'light' | 'dark') => 'light' | 'dark') => appearance.setPreference(fn(theme));
Expand Down Expand Up @@ -91,6 +96,7 @@ export function SignedInShell({ client, notice }: { client: SessionClient; notic
{id:'genvr',label:'GenVR',icon:<Icon name="dice"/>,run:()=>setPage('genvr')},
...(user.role==='owner'?[
{id:'terminal',label:'Terminal',icon:<UiIcon name="terminal" size={14}/>,run:()=>setPage('terminal')},
...(nativeEditor?[{id:'code',label:'Code',icon:<UiIcon name="code" size={14}/>,run:openCode}]:[]),
{id:'skills',label:'Skills',icon:<UiIcon name="file" size={14}/>,run:()=>setPage('skills')},
{id:'personalities',label:'Personalities',icon:<UiIcon name="contact" size={14}/>,run:()=>setPage('personalities')},
{id:'specialties',label:'Specialties',icon:<Icon name="user"/>,run:()=>setPage('specialties')},
Expand Down Expand Up @@ -120,13 +126,14 @@ export function SignedInShell({ client, notice }: { client: SessionClient; notic
{ id: 'rail', group: 'Actions', icon:<UiIcon name="sidebar" size={14}/>, label: rail ? 'Hide the sidebar' : 'Show the sidebar', run: () => setRail(v => !v) },
];
const active = chatState?.conversations.find(c => c.id === chatState.activeId);
const pageTitle = page === 'personalities'?'Personalities':page === 'trading'?'Trading':page === 'specialties'?'Specialties':page === 'chat' && active ? (active.title || 'Chat') : page === 'skills' ? 'Skills' : page === 'mcp' ? 'MCP connections' : page === 'terminal' ? 'Terminal' : pages.find(p => p.key === page)?.label ?? '';
const pageTitle = page === 'code'?'Code':page === 'personalities'?'Personalities':page === 'trading'?'Trading':page === 'specialties'?'Specialties':page === 'chat' && active ? (active.title || 'Chat') : page === 'skills' ? 'Skills' : page === 'mcp' ? 'MCP connections' : page === 'terminal' ? 'Terminal' : pages.find(p => p.key === page)?.label ?? '';
const openCards = boardState?.boards.reduce((a, b) => a + b.cards, 0);

const sidebarShown = rail && (page === 'chat' || ownSidebar);
return <div className="shell app-shell" data-theme={theme} data-rail={sidebarShown ? 'open' : 'closed'} data-section={page} lang="en" dir="ltr">
{choosingChat&&chat&&<NewChatDialog controller={chat} onClose={()=>setChoosingChat(false)}/>}
<header className="titlebar">
<WorkspaceModes onModeChange={mode=>{if(mode==='code')setPage('code');else if(page==='code')setPage('chat');}}/>
<div className="titlebar-tools">
<button type="button" className="tool" aria-label={rail ? 'Hide the sidebar' : 'Show the sidebar'} title={rail ? 'Hide the sidebar' : 'Show the sidebar'} aria-pressed={rail} onClick={() => setRail(v => !v)}><UiIcon name="sidebar" size={14}/></button>
{page === 'chat' && <button type="button" className="tool" aria-label="New chat" title="New chat" onClick={newChat}><UiIcon name="compose" size={14}/></button>}
Expand Down Expand Up @@ -173,6 +180,7 @@ export function SignedInShell({ client, notice }: { client: SessionClient; notic
<section className="page page-mcp mem-desk" hidden={page !== 'mcp'}><McpSection client={client} active={page === 'mcp'} slot={slot} onOpenAgents={()=>setPage('agents')}/></section>
<section className="page page-ui mem-desk" hidden={page !== 'terminal'}><TerminalSection client={client} active={page === 'terminal'} slot={slot} onOpenTerminal={openTerminal}/></section>
</>}
<section className="page page-ui" hidden={page!=='code'}><p role="status" className="fine-print">Select Code in the bottom bar to open or reconnect the project editor.</p></section>
<section className="page page-apps" hidden={page!=='apps'}><AppsWorkspace client={client} active={page==='apps'} sidebarSlot={slot} onOpen={id=>setPage(id)} onConnections={()=>setPage('mcp')} onAccountConnections={()=>{setSettingsTab('connections');setSettingsVisit(n=>n+1);setPage('settings');}} onChat={()=>setPage('chat')} onPermissions={()=>{setSettingsTab('permissions');setSettingsVisit(n=>n+1);setPage('settings');}}/></section>
{user.role==='owner'&&<section className="page page-trading" hidden={page!=='trading'}><TradingWorkspace client={client} active={page==='trading'} sidebarSlot={slot} onBack={()=>setPage('apps')}/></section>}
<section className="page page-genvr" hidden={page !== 'genvr'}><GenVRPage key={user.id} client={client} active={page === 'genvr'}/></section>
Expand Down
56 changes: 56 additions & 0 deletions packages/core-ui/src/WorkspaceModes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
import { validWorkspaceState } from './workspace-modes';
import type { WorkspaceMode, WorkspaceModeState } from './workspace-modes';

export function editorBounds() {
return {top:Math.max(32,Math.min(160,document.querySelector('.titlebar')?.getBoundingClientRect().bottom||48)),bottom:Math.max(0,Math.min(96,document.querySelector('.app-shell > footer')?.getBoundingClientRect().height||32))};
}

/** Native capability only: ordinary browser and remote-server clients remain unchanged. */
export function useNabuAvailability() {
const [available,setAvailable]=useState(false);
const bridge=typeof window==='undefined'?undefined:window.dproApp?.nabu;
useEffect(()=>{
if(!bridge)return;
let active=true;
const update=(value:unknown)=>{if(active&&validWorkspaceState(value))setAvailable(value.available);};
const unsubscribe=bridge.onState(update);
void bridge.status().then(update).catch(()=>{});
return()=>{active=false;unsubscribe();};
},[bridge]);
return available;
}

export function WorkspaceModes({onModeChange}:{onModeChange:(mode:WorkspaceMode)=>void}) {
const [state, setState] = useState<WorkspaceModeState | null>(null);
const ref = useRef<HTMLDivElement>(null);
const bridge = typeof window==='undefined'?undefined:window.dproApp?.nabu;
const previousMode=useRef<WorkspaceMode>('chat');
const onMode=useRef(onModeChange);onMode.current=onModeChange;
useEffect(() => {
if (!bridge) return;
let active = true;
const update = (value: unknown) => {
if (!active || !validWorkspaceState(value)) return;
setState(value);
if(previousMode.current!==value.mode){previousMode.current=value.mode;onMode.current(value.mode);}
};
const unsubscribe = bridge.onState(update);
void bridge.status().then(update).catch(() => {});
return () => { active = false; unsubscribe(); };
}, [bridge]);
useEffect(() => {
const header = ref.current?.closest('header') || document.querySelector<HTMLElement>('.titlebar');
if (!header || !bridge) return;
header.dataset.nabuMode = state?.mode || 'chat';
const measure = () => {const {top,bottom}=editorBounds();bridge.layout(top,bottom);};
measure();
const observer = new ResizeObserver(measure); observer.observe(header);
const footer=document.querySelector('.app-shell > footer');if(footer)observer.observe(footer);
return () => { observer.disconnect(); delete header.dataset.nabuMode; };
}, [bridge, state?.available, state?.mode]);
if (!bridge || !state?.available) return null;
return <div className="workspace-modes" ref={ref}>
<span role={state.error ? 'alert' : 'status'} className="workspace-mode-notice">{state.error || (state.busy ? 'Opening editor…' : '')}</span>
</div>;
}
3 changes: 2 additions & 1 deletion packages/core-ui/src/app-navigation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import type { NabuBridge } from './workspace-modes';
export type AppDestination = 'home' | 'settings' | 'account' | 'about';
type AppInfo = { version: string; connection: 'local' | 'remote' };
declare global { interface Window { dproApp?: { onNavigate: (fn: (page: AppDestination) => void) => () => void; info: () => Promise<AppInfo> }; } }
declare global { interface Window { dproApp?: { nabu?: NabuBridge; onNavigate: (fn: (page: AppDestination) => void) => () => void; info: () => Promise<AppInfo> }; } }
export function useAppNavigation(open: (page: AppDestination) => void) {
useEffect(() => window.dproApp?.onNavigate(page => { if (['home', 'settings', 'account', 'about'].includes(page)) open(page); }), [open]);
}
Expand Down
1 change: 1 addition & 0 deletions packages/core-ui/src/styles.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/core-ui/src/workspace-modes.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.workspace-modes { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.workspace-mode-notice { font-size: 12px; max-inline-size: 24em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-mode-notice:empty { display: none; }
.titlebar[data-nabu-mode="code"] .titlebar-tools { visibility: hidden; pointer-events: none; }
@media (max-width: 1000px) { .workspace-mode-notice { max-inline-size: 12em; } }
13 changes: 13 additions & 0 deletions packages/core-ui/src/workspace-modes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type WorkspaceMode = 'code' | 'chat';
export type WorkspaceModeState = { available: boolean; mode: WorkspaceMode; busy: boolean; error: string | null };
export type NabuBridge = {
status(): Promise<WorkspaceModeState | null>;
select(mode: WorkspaceMode, top: number, bottom: number): Promise<WorkspaceModeState | null>;
layout(top: number, bottom: number): void;
onState(callback: (state: WorkspaceModeState) => void): () => void;
};
export function validWorkspaceState(value: unknown): value is WorkspaceModeState {
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
return typeof v.available === 'boolean' && (v.mode === 'code' || v.mode === 'chat') && typeof v.busy === 'boolean' && (v.error === null || (typeof v.error === 'string' && v.error.length <= 160));
}
46 changes: 46 additions & 0 deletions packages/desktop/scripts/configure-editor-preview.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use strict';
/** Attach an existing Electron app to the local Dpro development checkout. No downloads. */
const fs = require('node:fs');
const path = require('node:path');
const { compatibleRuntime, loopbackOrigin } = require('../src/nabu-editor.cjs');

function initializeSettings(settings, origin) {
try {
fs.writeFileSync(settings, JSON.stringify({ mode: 'remote', remoteUrl: origin }), { mode: 0o600, flag: 'wx' });
return true;
} catch (error) { if (error.code === 'EEXIST') return false; throw error; }
}

function configure({ appDir, runtimeRoot, node, profile, origin }) {
if (process.platform !== 'darwin') throw Error('This local preview launcher requires macOS');
if (!loopbackOrigin(origin)) throw Error('The preview requires a loopback Dpro address');
if (!compatibleRuntime(runtimeRoot)) throw Error('Compatible editor runtime not found');
for (const value of [appDir, runtimeRoot, node, profile]) if (!value || !path.isAbsolute(value)) throw Error('Absolute paths required');
if (!fs.statSync(node).isFile()) throw Error('Node runtime not found');
const resources = path.join(appDir, 'Contents', 'Resources');
if (!fs.statSync(resources).isDirectory()) throw Error('Existing Electron app not found');
const destination = path.join(resources, 'app');
if (fs.existsSync(destination) || fs.existsSync(path.join(resources, 'app.asar'))) throw Error('Refusing to replace an existing application');
const main = path.resolve(__dirname, '../src/main.cjs');
fs.mkdirSync(profile, { recursive: true, mode: 0o700 });
const settings = path.join(profile, 'desktop.json');
initializeSettings(settings, origin);
fs.mkdirSync(destination);
fs.writeFileSync(path.join(destination, 'package.json'), JSON.stringify({ name: 'dpro-local-preview', productName: 'Dpro Agent', version: require('../package.json').version, main: 'bootstrap.cjs', private: true }));
fs.writeFileSync(path.join(destination, 'bootstrap.cjs'), `'use strict';\nObject.assign(process.env,${JSON.stringify({ DPRO_NABU_ROOT: runtimeRoot, DPRO_DESKTOP_NODE: node, DPRO_DESKTOP_PROFILE_DIR: profile })});\nrequire(${JSON.stringify(main)});\n`);
return appDir;
}

if (require.main === module) {
const keys = { '--app': 'appDir', '--runtime-root': 'runtimeRoot', '--node': 'node', '--profile': 'profile', '--origin': 'origin' };
const options = {};
try {
for (let i = 2; i < process.argv.length; i += 2) {
const key = keys[process.argv[i]];
if (!key || !process.argv[i + 1] || key in options) throw Error('Unknown, duplicate or incomplete option');
options[key] = process.argv[i + 1];
}
console.log(configure(options));
} catch (error) { console.error(error.message); process.exitCode = 1; }
}
module.exports = { configure, initializeSettings };
23 changes: 19 additions & 4 deletions packages/desktop/src/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@
* on this computer); nothing runs locally.
* - Quit (menu, Cmd/Ctrl+Q, last window closed) stops the backend first.
*/
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = require('electron');
const electron = require('electron');
const { app, BrowserWindow, Menu, ipcMain, session, shell, dialog } = electron;
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { createLocalRuntime, openLog } = require('./local-runtime.cjs');
const { resolvePayload, backendArgs, checkPayload } = require('./payload.cjs');
const { loadSettings, saveSettings, remoteOrigin } = require('./settings.cjs');
const { installNabuEditor } = require('./nabu-editor.cjs');

const discovery = require('./data-discovery.cjs');
// Keep the existing profile before Electron derives storage from the display name.
Expand All @@ -32,6 +34,8 @@ let log = null;
let appOrigin = null;
let quitting = false;
let stopped = false;
let nabuEditor = null;
let editorLog = null;

if (!app.requestSingleInstanceLock()) {
app.quit();
Expand Down Expand Up @@ -296,6 +300,13 @@ async function boot() {
hardenSession();
buildMenu();
mainWindow = createMainWindow();
const payload = resolvePayload({ packaged: app.isPackaged, resourcesPath: process.resourcesPath });
nabuEditor = installNabuEditor({
win: mainWindow, electron, getOrigin: () => appOrigin,
runtimeRoot: process.env.DPRO_NABU_ROOT || path.join(process.resourcesPath, 'nabu-runtime'),
node: process.env.DPRO_DESKTOP_NODE || payload.node, dataDir: path.join(settingsDir(), 'nabu-editor'),
onLog: line => { editorLog ||= openLog(path.join(settingsDir(), 'logs', 'editor.log')); editorLog.write(line); },
});
await applyMode();
}

Expand All @@ -307,9 +318,13 @@ app.on('before-quit', event => {
event.preventDefault();
if (quitting) return;
quitting = true;
stopLocal()
.catch(error => { if (log) log.write(`stop failed: ${error.message}`); })
.finally(() => { stopped = true; if (log) log.close(); app.quit(); });
Promise.resolve(nabuEditor?.requestClose()).then(async allowed => {
if (allowed === false) { quitting = false; return false; }
try { await nabuEditor?.stop(); } finally { await stopLocal(); }
return true;
})
.catch(error => { quitting = false; if (log) log.write(`stop failed: ${error.message}`); editorLog?.write(`Editor shutdown failed: ${error.message}`); return false; })
.then(allowed => { if (allowed === false) return; stopped = true; if (log) log.close(); editorLog?.close(); app.quit(); });
});

app.on('web-contents-created', (_event, contents) => {
Expand Down
Loading
Loading